Insured inventory link
A tokenized link an adjuster emails to the INSURED (the homeowner who suffered the loss), letting them build their own inventory for one claim. They open it on a phone, walk their home room by room, add photos or videos with a note on each file, or type items into a table, and it all lands on the adjuster's claim.
This is the app's only public, unauthenticated write surface. Every other
router sits behind Depends(get_actor) and an Adjust Square JWT. Read this page
and the module docstring in backend/app/api/routes/insured_public.py before
changing anything under /api/public/.
Why it exists
The two existing touch points with the outside world both happen late: a shopping profile sent out to be filled in, and the finished estimate coming back. There was nothing at the START of a claim. This is that step. It also turns the most expensive part of a contents claim, remembering and listing hundreds of items, over to the only person who actually knows the answers.
Shape
A guided, room-by-room wizard rather than one enormous form. In each room the homeowner can do either or both, and can skip the room entirely:
- Add photos or videos, with a note on each file. The note is the valuable part. See How a note reaches the AI.
- Type the inventory directly, in a table. Ten columns, of which only Title and Quantity are required.
Data model
Four tables, in backend/app/models/insured_link.py.
| Table | Holds |
|---|---|
insured_links | One invitation, scoped to exactly one claim. Token hash, recipient, lifetime, email delivery state, and progress. |
insured_draft_rooms | A room THEY added, before submit. |
insured_draft_items | One typed row in the homeowner's table, before submit. |
insured_draft_files | One photo or video they uploaded, before submit. |
All four are whole new tables, so create_all builds them and no ALTER lines
were needed in init_db for the tables themselves.
Why there is a draft stage
Nothing the homeowner enters touches the claim until they press Submit. They will not finish in one sitting, and half-typed rows ("blue arm", quantity blank) must not appear on the adjuster's claim, consume claim-unique item numbers, or get swept into a send to Contents Estimation. So the drafts live in their own tables and are materialised in one transaction at submit. Reading the drafts back is also what makes resume work.
File BYTES are the exception: they are written to disk as they arrive, under
<storage>/insured/<link_id>/, so a phone on hotel wifi never re-uploads. Only
the row that turns a file into a StagedPhoto waits for submit.
The typed table's columns
Every column maps onto a field that already exists on InventoryItem. There is
no parallel model.
| Column | InventoryItem field | Required |
|---|---|---|
| Item No. | item_number | automatic |
| Photo | photos / ItemPhoto rows | no |
| Title | item_name | yes |
| Brand | brand | no |
| Model No. / SKU | model_number | no |
| Quantity | quantity | yes |
| Estimated Price | estimated_value | no |
| Age Years | age_years | no |
| Age Months | age_months | no |
| Notes | notes | no |
model_number is used rather than sku. Both columns exist, but a homeowner
reading a label off the back of a television has one number, not two, and
model_number is the field the rest of the app already displays and exports.
Validation is per field, applied as the user leaves it, in the browser AND on the server (a public endpoint cannot trust its client). Quantity is a positive integer with a minimum of 1; Age Years and Age Months are non-negative integers; Estimated Price is a non-negative currency value.
Age Months of 12 or more rolls into years. 18 months becomes 1 year and 6
months; 2 years and 30 months becomes 4 years and 6 months. It is not rejected
and not truncated. Someone thinking in months is answering the question
correctly, and refusing them would be pedantry at the worst possible moment. The
wizard shows the normalised value as soon as they leave the field, so nothing
changes behind their back. See normalise_age in backend/app/schemas/insured.py.
Security
The token is a secrets.token_urlsafe(32) value: 256 bits of entropy, 43
characters. It is the only credential.
- Only the hash is stored. The row keeps
sha256(token)and nothing else, so a database leak yields no working links. Lookup hashes the presented token and compares hashes. - One claim, and only one. Every room and draft row is checked against the
link's own
claim_id. A valid token for claim A can do nothing to claim B, even with a guessed room id. - The payload is a thin slice. The homeowner sees the claim number, the name of who is asking, the room names, and what they themselves entered. Never the team, the adjuster's account, policy limits, pricing, credits, other rooms' inventory, or anything the AI produced. The allowed set is pinned by a test.
- Unknown, revoked, and expired all 404 identically, so a prober cannot tell which links exist.
- It never answers 401. A 401 from
/api/*is what the frontend session guard watches to log a user out, and the homeowner has no account to log back into.sessionGuard.mjsalso excludes/api/public/outright as a second line. - It cannot cost money. Nothing under
/api/public/calls the AI or spends a credit.
Lifetime and revocation
Links expire after INSURED_LINK_TTL_DAYS (30 by default). An adjuster can
revoke one at any time, which bites on the holder's very next request because
every public route resolves through insured_link.resolve(). Revoking closes
the door but never undoes work already submitted.
Submitting more than once
A submit does not close a link. People remember more the next day, and refusing that would push the work back to the adjuster by email, which is the job this feature exists to remove.
What a submit freezes is the ROWS it sent. Each draft row carries its own
submitted_at: submit() takes only the rows where it is NULL, materialises
them, and stamps them. So a second press, whether a double tap or a genuine
visit a week later, can never send the same row twice, and pressing Submit with
nothing new is a harmless no-op rather than an error.
The wizard shows sent rows still in place, marked Sent with a lock and rendered read-only and grey, because seeing what has already gone is how someone knows what is left to add.
Submission history needs no table. A submit stamps every row it sends with
one timestamp, so the stamp already IS the batch: grouping draft rows on
submitted_at reconstructs "what did I send, and when". That drives the
Submissions button beside Submit, and the review screen's split between
"Already sent" and "About to send", which is the question on a repeat visit.
Those totals are summed from the per-submission counts rather than the link's
own counters, because the counters have no audio field and would quietly
undercount a voice memo. require_editable() enforces it server-side on every endpoint that
changes or removes an existing row; a test reads the source of those handlers and
fails if one of them stops calling it. Link-level counts accumulate across
submits, since they describe what the adjuster has received in total.
Resend rotates the token. Because only the hash is stored, the raw token cannot be recovered to put in a second email, so a resend mints a new token on the SAME row: everything the homeowner has entered survives, but the URL in the earlier email stops working. That is the deliberate trade against storing the raw token, which would make a database leak hand over live claims. The adjuster's screen says so before they confirm.
The per-row stamp, and the migration that broke it
submitted_at on a draft row is the whole locking mechanism, so anything that
writes it outside submit() is dangerous. One thing did.
Per-row locking arrived after links already existed, and rows sent under the old whole-link scheme have a NULL stamp that the new code reads as "not sent yet", which would send them a second time. A backfill stamps those with the link's own submit time. The first version of it was wrong in two ways at once:
WHERE submitted_at IS NULL AND link_id IN (SELECT id FROM insured_links WHERE submitted_at IS NOT NULL)
It ran on every boot, and that predicate matches every row the homeowner types
after their first submit, for ever. So a server restart silently marked their
unsent work as sent: locked, greyed, never delivered to the adjuster, and
skipped by the next real submit because it already looked sent. The giveaway in
the data is a row whose submitted_at is EARLIER than its created_at, which
is impossible.
Two things fix it, and both are needed:
_run_onceincore/database.pyrecords each data migration in adata_migrationstable, so it runs once on a database and never again. Its docstring carries the rule for when to reach for it: use it unless you can show the WHERE clause stops matching a row it has already fixed. The other backfills in that file are self-limiting and stay as they are.- The backfill also gained
AND created_at <= l.submitted_at. A row that did not exist when the link was submitted cannot have been part of it, so even without the marker it could no longer take new work.
A second migration (insured_draft_unstamp_impossible_v1) repairs the damage by
clearing stamps that cannot be real: a row sent before it was created, and an
ITEM with no name, since submit() drops unnamed rows before it stamps
anything. Nothing legitimate is undone.
Limits
Nobody authenticates to reach these endpoints, so the ceilings are the defence.
| Limit | Setting | Default |
|---|---|---|
| Largest single photo | INSURED_MAX_FILE_MB | 80 MB |
| Largest video (chunked) | INSURED_MAX_VIDEO_MB | 1500 MB |
| Chunk size for a video | INSURED_CHUNK_MB | 16 MB |
| Total bytes one link may ever upload | INSURED_LINK_MAX_TOTAL_MB | 4000 MB |
| Files per link | INSURED_LINK_MAX_FILES | 2000 |
| Typed rows per link | INSURED_LINK_MAX_ITEMS | 5000 |
| Reads per minute per token | INSURED_RATE_LIMIT_READS_PER_MINUTE | 120 |
| Writes per minute per token | INSURED_RATE_LIMIT_WRITES_PER_MINUTE | 120 |
| Uploads per minute per token | INSURED_RATE_LIMIT_UPLOADS_PER_MINUTE | 60 |
| Unknown-token requests per minute per IP | INSURED_RATE_LIMIT_UNKNOWN_TOKEN_PER_MINUTE | 20 |
The byte total is a LIFETIME ceiling, not a quota of what the link currently holds: deleting an upload does not credit it back, so upload-then-delete cannot be used to push unlimited bytes through.
INSURED_MAX_FILE_MB applies to PHOTOS only, and is kept under the ~100 MB
Cloudflare Tunnel body limit (see Backend API).
Photos are uploaded one per request rather than batched, because a dropped
connection on hotel wifi should cost one photo rather than the whole batch.
Videos and voice recordings go up in chunks, so they are not bound by what
one request can carry. Two minutes of phone footage is comfortably 200-400MB,
and an 80MB single-request limit refused exactly the walkthrough video this
step wants most. POST /uploads/init, GET /uploads/{id},
PUT /uploads/{id}/chunks/{n}, POST /uploads/{id}/complete, and
DELETE /uploads/{id} mirror the authenticated chunked path
(routes/uploads.py) with the scoping changed from "the caller's team" to
"this link": the manifest records the link_id and every request re-checks
it, so one token cannot see, continue, finish, or abort another's upload. The
upload id is validated as a UUID, chunks are written to a temporary name and
renamed so a crash cannot leave a part that looks complete, and complete
refuses anything but a gap-free 0..n-1 run whose assembled size matches what
init was told. On the server each chunk is written through aiofiles and the
parts are joined by services/chunk_assembly.py on the thread pool, so a
homeowner's slow upload never holds the event loop for anyone else. The
browser side (lib/insuredApi.ts uploadInsuredVideo) is deliberately the same
mechanism as the app's own lib/api.ts chunkedUpload:
- Four chunks in flight at once over
XMLHttpRequest, with the chunk size from the server. The planning, the percent maths, and the retry policy are the shared,node --testedlib/uploadChunks.mjs(see the chunked uploads notes for why). - A progress bar and percentage on the pending row
(
components/insured/InsuredRoomMedia.tsx), the same.progress-track/.progress-fillas the adjuster'sRoomUpload. A 300MB upload on hotel wifi takes minutes and a spinner that never moves is indistinguishable from a hang, which is exactly what a homeowner reported on production. Photos go in one request with no byte progress, so their row keeps a spinner. The percent is capped at 99 while bytes are moving; once every chunk is up the row says Finishing up while the server joins the parts and takes the poster frame, so the bar never sits full saying nothing. - Each chunk is retried up to three times on a network drop, a timeout,
a rate limit, or a server error (
sendChunkWithRetry), and never on a refusal (4xx), which would only come back the same way. - A failed row offers Retry. The parts that did arrive are left on the
server, and Retry asks
GET /uploads/{id}which ones those are, then sends only the rest. The status carries the filename and size and the browser checks them against the file it is holding, so a resume can never stitch two different videos together; a mismatch, or a swept upload, starts over. An upload refused outright (too big, wrong type, link expired) is deleted at once and Retry starts fresh. Dismiss deletes the parts. - Abandoned parts are swept. Because a failure keeps its parts, a closed
tab would otherwise leave them forever.
initrunsservices/insured_link.sweep_stale_chunks(off the loop, best effort), which removes any upload directory nothing has written to forSTALE_CHUNK_HOURS(24). A part landing counts as activity, so an upload still in progress is never swept from under it.
Rate limiting is backend/app/core/rate_limit.py: fixed windows, in-process.
With N workers the effective ceiling is N times the configured number, which is
sized for. It bounds runaway scripts and retry loops by orders of magnitude; it
is not an exact fleet-wide limit, and if one is ever needed the module should be
replaced with a Redis-backed one rather than extended.
What submit produces
insured_link.submit() turns the drafts into real work, in one transaction:
| Draft | Becomes | Status |
|---|---|---|
| Room photos | StagedPhoto rows on ONE photo job per room, each carrying its note in source_description | awaiting_grouping |
| Room videos and voice notes | One job each, bytes copied into the upload directory, length probed (services/media_duration.py) so the quote prices it | awaiting_confirmation |
| Typed rows | InventoryItem rows on one job per room, built directly | job is complete |
| Rooms they added | A real Room on the claim, but only if this submit brings it something |
Two rules it exists to enforce.
Nothing spends a credit and nothing starts the AI. Every job is parked in a
status that waits for the adjuster to press Process, which is where the existing
quote, confirmation, and up-front charge already live. The team that owns the
claim pays, at the moment the adjuster chooses to, exactly as if they had
uploaded the photos themselves. An empty credit balance therefore can never
surface as an error in front of the homeowner. This is the same reasoning that
makes the Contents Capture importer park video at awaiting_confirmation.
Note that video deliberately parks at awaiting_confirmation even when billing
is OFF, which differs from finalize_single_media_upload (that starts the
pipeline immediately when billing is off). Here the person who uploaded is not
the person who pays, so the adjuster confirms every time.
The length must be on the job before the adjuster is quoted. Video and
audio are priced per 20 second block of ProcessingJob.duration_seconds, and
the quote (_job_estimate_credits in routes/jobs.py) reads that field
rather than probing the file. Until 2026-09-14 the insured submit never set
it, so every homeowner video and voice note was quoted, and processed, for
zero credits, while the adjuster's own upload of the same footage was
priced normally. _materialise_timed_media now probes each file with the
shared services/media_duration.probe_duration_seconds (the same helper the
Contents Capture importer uses), and test_a_homeowner_video_is_priced_by_its_length
pins the quote to estimate_video_credits / estimate_audio_credits. A file
ffmpeg cannot read is priced as 0 and still accepted, which is the fail-open
rule every other upload path follows.
A typed row costs nothing, ever. It is built straight into an
InventoryItem with confidence 1.0 and visual_evidence of "Entered by the
homeowner", on a job created already complete with zero billed credits. The AI
is never called, because the homeowner already said what the item is. This is
the same principle as StagedPhoto.manual_name in the grouped-photo pipeline.
It is done directly rather than through a StagedPhoto because a typed row
carries brand, model, quantity, price, age and notes, which a StagedPhoto has
nowhere to put, and because a row may have no photo at all.
Nothing is sent to Contents Estimation. Sending freezes a claim's items (see
Claim.items_locked), so auto-sending would remove the adjuster's only chance
to correct what a homeowner entered. The inventory stops in ContentsVision, and
an activity entry records that it arrived.
Item numbers are claim-unique and continue from get_max_item_number_for_claim,
read at submit so anything the adjuster added while the homeowner was typing is
counted.
A room they added arrives with its contents, not before. Adding a room is
one tap and abandoning it costs nothing, so creating a Room the moment they
type a name fills the adjuster's claim with rooms nobody meant to keep, and they
cannot tell those from rooms genuinely still to come. So a room the homeowner
adds is a draft like everything else (insured_draft_rooms) and becomes real in
_materialise_draft_rooms, at the same moment its first contents do. A name
that already exists on the claim adopts that room rather than making a second:
the adjuster may have created the Garage meanwhile.
Materialising rewrites every draft row pointing at the draft room, including the
blank ones this submit is not carrying, so nothing is left holding an id that no
longer means anything. That UPDATE is deliberately synchronize_session=False
and the caller fixes its own loaded rows from the returned map: expiring them
instead would leave async SQLAlchemy trying to reload lazily from synchronous
code, which raises MissingGreenlet.
The wizard therefore tracks which room tab is open by NAME, not by id or position, because a drafted room changes both the moment it is submitted.
Everything is stamped with who sent it. ProcessingJob.uploaded_by and
InventoryItem.uploaded_by carry the homeowner's EMAIL ADDRESS, because that is
the only identifier somebody without an account has: the name field is optional
and self reported. An adjuster's own uploads carry their User.name instead, so
the column reads as a person either way and the address makes the source
obvious. ProcessingJob.origin records insured_list or insured_media
alongside it, which is what the room page branches on to draw a typed list with
a pencil icon and a tinted badge rather than the camera icon it would otherwise
borrow from its source_type of photo.
A typed list sits in the Uploads table with everything else. It had its own Typed by the homeowner panel above the tabs for a while, on the reasoning that it is not really an upload, but the panel and the table then said the same thing in two places and the row was the half people already understood. One row in a table they already read beats a second place to look.
INSURED_LIST_ORIGIN_SQL in core/database.py stamps origin on typed lists
created before that column existed, which otherwise read as ordinary photo
uploads and drew a camera. It is self-limiting and so deliberately does NOT
go through _run_once: a row it has fixed has origin = 'insured_list' and
stops matching origin = ''. The rest of its predicate (the name, source_type = 'photo', status = 'complete', no AI request, no charge) describes a job
that can only ever have been a typed list. Three tests cover it: it stamps the
old rows, it leaves an adjuster's own upload and any look-alike alone, and a
second run does not overwrite a value somebody has since changed.
The adjuster is told. After the commit, _notify_adjuster emails whoever
sent the invitation (the inviter, a real user row with a real address, not the
claim's adjuster_name field, which is free text and may name somebody who does
not use the system) with a button straight to the claim. It can never fail the
request: the homeowner's work is already on the claim, so a mail outage must not
come back to them as an error for something they did correctly.
A row the homeowner opened and never named is dropped at submit rather than refusing the whole submission. They are finished, and an empty row is not worth a blocking error in front of someone who has just entered two hundred of them.
Grouping, and why a group is one card
Grouping is the highest-value thing anybody does on this screen. Four angles of one dresser, ungrouped, become four items the adjuster merges by hand and cost four AI calls; grouped, they are one call and one item described from every side. So the grid is built to make it easy and to make it obvious it worked.
The grid walks entries, not files. A group renders as ONE card: a mosaic of up to four of its photos with the real total in the corner, captioned "One item". Rendering each member as its own card, each captioned "one item", made people think they had made four items, which is the exact opposite of what grouping does. Four is the ceiling because the tile is 176px tall and a fifth photo in it is a smudge, which is why the count is said out loud beside it.
Drag one photo onto another to group them, with the tick boxes and the button kept as they were. Drag is the fast way and is invisible unless somebody is told, so there is one line of copy above the grid; the tick boxes are the only way that works on a phone, where there is no drag at all, and the only way from a keyboard.
Three details that are easy to get wrong:
- The grab handle is the picture, not the card. A draggable element makes its whole subtree draggable, so a draggable card means dragging to select the text in the note field picks the card up instead.
- The drop sends every id from BOTH sides, including members the drag never
touched. Merging two groups mints a fresh key (see
group_files), so anything left out would keep its old key and quietly split off on its own. - A group's members come from the unfiltered list, so a search matching one photo still shows the whole group. Half a group is a lie about what will be sent.
The drag handlers sit on a plain element rather than the motion.div, because
Framer Motion declares its own onDragStart for pan gestures and the two do not
share an event type.
How a note reaches the AI
The homeowner attaches a note to each individual file: "grandmother's china cabinet, walnut, bought 1998", or "the TV is behind the boxes on the left". This reuses the mechanism built for PDF import, unchanged:
InsuredDraftFile.note
-> StagedPhoto.source_description (at submit)
-> pipeline._source_note() (collects and de-duplicates per group)
-> claude_analyzer._with_source_note() (appends SOURCE_NOTE_BLOCK)
-> the model's USER message
SOURCE_NOTE_BLOCK is explicit about the ORDER: identify the items from the
photo first, THEN use the note to confirm. Told the other way round a model
works down the note hunting for matches and reports items that are not in the
picture, which is exactly the failure a homeowner's run-on note invites. The
instruction lives in the USER message rather than the system prompt on purpose,
because a team can replace the photo system prompts entirely
(services/prompt_settings.py) and the rule has to survive that.
A note on a VIDEO is kept on ProcessingJob.notes instead. Video items are
named from the footage and its audio, so the note is a record of what they said
rather than a hint fed to the frame prompts.
The wizard's shape
Designed to one rule: one obvious action per screen (Krug, "Don't Make Me Think"). The person reading it has just lost their home, is on a phone, and has hundreds of things to remember, so anything that is not the action is demoted, moved, or cut. Worth knowing why each decision was made, so they do not creep back:
- Nothing is asked that we do not need. No name, no sign-in, no account. The welcome screen is a sentence and a Start button. The link already identifies the claim and the adjuster knows who they sent it to, so asking a name was a field that bought nothing.
- Every room is a tab across the top, with a
+at the end to add one. Rooms began as a sequence marched through with Next and Skip, which is wrong for the task: someone who remembers the garage halfway through the kitchen should simply go there. Each tab shows a count of what that room holds, so what is still empty is obvious at a glance. - The top-right button is Submit, not Next. With every room a tab there is no "next" any more, and the only thing left to do from that screen is finish. It opens the review screen rather than sending, because sending is irreversible and freezes the claim's items.
- Two tabs inside a room: "Media" and "Inventory Table". They started as a primary action plus a disclosure link, but the link read as an afterthought for something that is genuinely half the feature. A room they have already typed in opens on the Inventory Table tab, so their work is in front of them.
- The Inventory Table tab opens with one blank row already there. Someone who has just chosen it plainly intends to add an item; making them press Add first asks them to state the obvious.
- Both tabs can be searched, and the Inventory Table can be filtered. Media matches file name and note. The Inventory Table has a search box across every column PLUS real per-attribute rules ("Quantity is more than 1"), which a search box cannot express. Which conditions are offered depends on the column's type, so a nonsense rule (a price that "contains") cannot be built. Rules are ANDed, shown as removable chips, and the wording is "is more than" rather than ">", because a symbol is one more thing to decode.
- Media is a three-across grid with a real photo, not full-width rows with a 64px thumbnail beside an ocean of space. A photo you cannot see is no help when the task is remembering what a thing was. Tapping one opens the same lightbox the item photos use; videos show a placeholder tile and are not opened, since there is no reason for someone to re-watch their own clip here.
- Uploads are paged, six at a time. Uploading clears any active search and jumps to the page the newest file landed on, so a file can never be added somewhere invisible.
- Adding a room lives on the review screen, once, not on every room.
- One loud button. Back and Skip exist because they are needed, but only Next is prominent.
- The item table gets the width, because it has ten columns. The welcome, review, and done screens stay narrow, where long lines hurt reading.
- The note on a photo is marked optional, in the placeholder and in the walkthrough. It is the field most likely to make someone think they have to write something before they can move on, and they do not.
- An item's photos collapse to one thumbnail with a count. A stack of thumbnails in a table row stretches the row to the height of the stack and buries every other field. Tapping opens a slideshow (
InsuredPhotoViewer) where the whole set lives, with add and remove; removal disappears once submitted, like everything else.
The guided walkthrough
components/insured/InsuredTutorial.tsx. It points at one real control at a
time: the page dims, the target glows, and a callout beside it says what that
control does. It opens every time the link is opened, which is deliberate:
this is a page most people see once or twice, months apart, with no chance to
build a habit. "Hide tutorial" dismisses it, and a "Show me how" button in the
bottom right brings it back.
Three things worth keeping:
- It never traps anyone. The dim is a huge
box-shadowspread on a hole element withpointer-events: none, so the whole page stays clickable while the tour is up. An overlay that blocks the thing it is teaching is worse than no overlay. - Steps can drive the page. A step with an
onEnterruns it on arrival, so the steps that live inside a tab switch to that tab first. The tour therefore demonstrates the tabs rather than silently skipping whatever is not currently on screen. Such a step is never skipped for a missing target: the overlay waits up toWAIT_MSfor it to appear. - One step makes them do it, and offers nothing else. The step on the
Inventory Table tab is
awaitClick: it targets that BUTTON rather than the whole toggle, hides Back and Next entirely, and advances only when the button is really clicked. Being told about a tab and tapping it are different things, and only the second is remembered; removing every other control also removes every way to get it wrong. The overlay is click-through precisely so the tap lands, and the step after it carries anonEnter, so nobody ends up on the wrong tab whatever they do.
Order and reading level are part of the design. The tour finishes the Media tab completely before it mentions the Inventory Table, because jumping between the two teaches neither. Every line is written to be understood by a ten year old: short sentences, plain words, one idea each, and it says what a thing is FOR rather than what it is called. The reader may be exhausted, on a phone, and reading it once. If a line grows past about fifteen words, it has drifted; the same bar applies to the wizard's own on-screen copy.
- A step with no target and no
onEnteris skipped, in both directions, so a missing element degrades quietly rather than highlighting nothing.
Skipping is a safety net, not a licence. A step is at its most useful on a FIRST visit, which is exactly when the room has no photos and no rows, so a step pointing at something that only appears once content exists is invisible to the people who need it and only ever shows for those who do not.
Two steps were removed for precisely this: one on the per-photo note box (needs one upload) and one on the photo search (needs two). The note is now explained on the upload step instead, where the reader is standing when it matters.
How to check: open a link on a claim whose rooms are empty, press Start, and walk the whole tour. Every step must appear. That is the only way this class of mistake shows up, because with content on the page everything looks fine.
Positions come from getBoundingClientRect on the real element and are
recomputed on scroll, resize, and step change, so the highlight cannot drift out
of line with the layout. Targets are data-tour attributes.
Email and delivery tracking
The invitation is sent through Mailgun, the same transport as the support email,
via backend/app/services/insured_email.py. OFF by default: with no
MAILGUN_API_KEY the send returns not_configured without touching the
network, so local dev and the test suite never send mail. The adjuster's screen
reports that state rather than implying the mail went.
A domain-scoped sending key is enough to send, and is the right choice: the
app only ever posts to <domain>/messages. Reading delivery events (below)
additionally needs a key that can read the Events API.
What the email says, and why
Sent as HTML with a plain-text alternative. The rules are product decisions and
are pinned by tests/test_insured_email.py:
- It speaks as the FIRM, not the adjuster. The insured is the firm's customer; they may not recognise whoever opened the claim, and staff change during a claim while the firm does not. The header carries the firm's name.
- The raw link never appears in the HTML. It is the href behind a "Start my list" button, with no "if the button does not work" fallback. These tokens are long, and a wall of random characters both reads as phishing and invites pasting half of it. The plain-text part does carry the URL, because a text-only client would otherwise get an email it cannot act on.
- They upload photos or videos. Nothing is captured inside the app, so "take photos" would be wrong.
- No Reply-To. It says plainly that replying does nothing and to contact the firm, whose details the insured already has. A reply landing in our support inbox would be answered by people who know nothing about their claim.
- The footer reads "sent by Adjust Square on behalf of
<firm>".
The header shows the firm's NAME. It will show the firm's mark as soon as one
exists, but nothing in this system stores one: Team has no logo column, and
the SSO token carries only the team's id, name, and tokens. build_invite_html
already takes a firm_logo_url and renders it when given, and drops any
localhost or plain-http URL rather than showing a broken image, which is the
most visible way an email can look untrustworthy. Populating it needs either a
new SSO claim or an upload in Settings.
Delivery state is learned two ways, both feeding the ONE function that applies
it (insured_link.apply_email_event), so push and pull can never disagree:
- Polling (the one that always works). When the adjuster opens the
invitations panel,
refresh_email_deliveryasks Mailgun's Events API what has happened to each message. No public URL and no webhook registration, so it works on a laptop exactly as in production. Throttled per link, skipped once a link reachesclicked, and entirely best-effort: a slow or dead Mailgun costs a stale status, never the adjuster's page. It needs a key that can read events; a domain sending key cannot, and the failure is logged at info and ignored. - The webhook (real-time, optional). The message carries
v:insured_link_id, which comes back on every Mailgun webhook event and is how a callback finds the row.POST /api/public/insured/mailgun-eventsrecordsdelivered,opened,clicked, and bounces. It is public because Mailgun calls it, but not unauthenticated: every callback is HMAC-verified againstMAILGUN_WEBHOOK_SIGNING_KEY, and with no key configured every call is rejected, which is the correct closed default for an endpoint that writes to our rows. Status only ever moves forward, so Mailgun's at-least-once retries are harmless; a bounce is the one event allowed to overwrite a later state, because the adjuster needs to know the address does not work.
Trust these signals differently. email_opened_at comes from a tracking
pixel, and Apple Mail Privacy Protection loads it whether or not a human looked,
so its presence means little and its absence means less. link_first_opened_at,
started_at, and submitted_at are measured on our own server and are facts.
The UI says so.
Endpoints
Public (no auth, token in path). See routes/insured_public.py.
| Method & path | Purpose |
|---|---|
GET /api/public/insured/{token} | Everything the wizard renders, including on resume. Records that the link was opened. |
POST /api/public/insured/{token}/identify | Record their name. Never a gate. |
POST /api/public/insured/{token}/rooms | Add a room. An existing name returns that room rather than a duplicate. |
POST /api/public/insured/{token}/rooms/{room_id}/files | One photo, with its note, in a single request. |
POST /api/public/insured/{token}/uploads/init | Open a chunked upload for a video or voice recording. Checks the type, the size cap, and the link's byte ceiling before any bytes move; sweeps abandoned uploads. Returns { upload_id, chunk_size, received }. |
GET /api/public/insured/{token}/uploads/{id} | Which parts have arrived, plus the filename and size, so a Retry can resume with only the missing parts. |
PUT /api/public/insured/{token}/uploads/{id}/chunks/{n} | Store one part (raw body). Idempotent. |
POST /api/public/insured/{token}/uploads/{id}/complete | Join the parts, take the poster frame, record the file. |
DELETE /api/public/insured/{token}/uploads/{id} | Throw a part-finished upload away. |
POST /api/public/insured/{token}/rooms/{room_id}/items | Add a typed row. |
PATCH /api/public/insured/{token}/items/{item_id} | Save one field. |
DELETE /api/public/insured/{token}/items/{item_id} | Remove a row and its photos, on disk too. |
POST /api/public/insured/{token}/items/{item_id}/photos | A photo for the table's Photo column. |
PATCH /api/public/insured/{token}/files/{file_id} | Edit a file's note. |
DELETE /api/public/insured/{token}/files/{file_id} | Remove a file. |
GET /api/public/insured/{token}/files/{file_id}/content | Serve one of their own photos back, so a resumed session can show it. Videos and audio are served with Accept-Ranges so a player can seek. |
GET /api/public/insured/{token}/files/{file_id}/thumb | A small copy of one photo (longest edge 640px), for the media grid. Made on first request and written beside the original, exactly as the video poster is, so photos already uploaded need no migration and the work happens once per file. Falls back to the full file if it cannot be made: slow beats blank. 404 for video and audio, which use /poster. |
GET /api/public/insured/{token}/files/{file_id}/poster | The still frame from a video, so a card can show what a clip is without pulling the video down. Generated on first request if missing. |
POST /api/public/insured/{token}/submit | Turn the drafts into work on the claim. |
POST /api/public/insured/mailgun-events | Mailgun delivery webhook, HMAC-verified. |
Adjuster-facing, behind get_actor and team-scoped. See routes/insured.py.
| Method & path | Purpose |
|---|---|
GET /api/claims/{claim_id}/insured-links | Every invitation on this claim, newest first. Never returns a token. |
POST /api/claims/{claim_id}/insured-links | Invite the insured. Re-inviting the same address resends their existing link, keeping their work. |
POST /api/insured-links/{link_id}/resend | Send again. Rotates the token; see above. |
POST /api/insured-links/{link_id}/revoke | Shut a link immediately. |
Frontend
-
frontend/src/app/insured/[token]/page.tsxis the wizard. It renders OUTSIDEAppShell, which treats/insured/as a public prefix alongside/verify/, so there is no sidebar, no credit balance, and nothing else belonging to the adjuster. -
frontend/src/lib/insuredApi.tsis its only API client, deliberately separate fromlib/api.tsbecause every call in that file attaches the adjuster's bearer token. -
components/insured/InsuredItemTable.tsxrenders the ten columns as a real table frommdup and as a stacked card per row below it, both driven by oneCOLUMNSlist so they cannot drift or fall out of order. Most people open this on a phone, where a ten-column table is unusable, but the column set is what the estimate expects, so it is reflowed rather than reduced.Shift + Enter adds a row and puts the cursor in its Title. Shift, not Cmd/Ctrl: it is the one modifier that is the same key, in the same place, with the same name on a Mac and on Windows, so the hint printed on the button is one string that is true for everybody (it used to sniff the platform and render
Ctrl + EnterorCmd + Enter). There is notextareaanywhere on this screen, so it cannot swallow a newline somebody wanted. A box that already means something by Enter opts out withdata-enter-own(the filter value box here, the new-room box on the wizard), and the shortcut is inert while a search or filter is on, matching the button, which hides then.startNewItemjudges "is there already a blank row?" from the DOM, not from state, viaisBlankOnScreen. The field boxes are uncontrolled and only save on blur, soitemsstill says a row is empty while the person is looking at the words they just typed into it. Judging from state alone meant that pressing the shortcut mid-typing found the row they were working in, re-focused it, selected the text they had just typed, and gave them no new row at all. Both tests now have to agree, because state alone lags the screen and the screen alone says nothing about a row that is filtered out of the DOM. It also only reuses a blank row iffocusTitleactually succeeds: a row we cannot put the cursor in is not somewhere they can type, so they get a fresh one rather than a keypress that appears to do nothing. -
components/insured/InsuredRoomMedia.tsxis the per-file upload and note. It pages nine ENTRIES at a time, where an entry is a lone file or a whole group of photos, so a group never straddles a page break. The pager is the sharedcomponents/Pagination.tsx: Previous, numbered pages, and Next as one tight group at the right-hand end, with the "1 to 9 of 100" summary pushed to the far left. Numbered pages matter here because a room can easily run to a hundred photos, and reaching page eight by pressing Next is most of the work. Long runs collapse to1 ... 5 6 7 ... 12, and to1 ... 6 ... 12belowsmwhere the arrows also lose their words and keep only their chevrons. The room screen carriespb-28for this: Show me how is fixed to the bottom-right corner of the window, so without that padding the pager comes to rest underneath it once you scroll to the foot of the page.The grid shows thumbnails, never the originals (
insuredThumbUrl, which hitsGET .../files/{id}/thumb). This is not a nicety. Uploads are stored at their original size, and a phone or camera photo is routinely 4608x3456: 16 megapixels, about 64MB once a browser has decoded it. A page of nine cards, where a group card holds up to four photos of its own, was decoding on the order of 638MB of bitmap over 28MB of transfer to fill tiles a couple of hundred pixels wide. Measured on a real room, the same page is now 14MB decoded over 0.5MB of transfer. That weight is what made dragging a card crawl. The lightbox and the AI still get the full file.One photo can leave a group without dissolving it. Splitting the whole group was the only way out, which is far too blunt when nine of ten photos really are the same thing and one is not.
removeFromGroup(file)clears just that file'sgroup_key; the public ungroup endpoint already takes a list of ids and clears exactly those, so no backend change was needed. It is offered in the photo viewer (onUngroupOne), which is the one screen where you are looking at a single photo and can tell it does not belong.If only one photo would be left behind, it comes out too. A group of one is not a group, and leaving it wearing a group key draws a card captioned "One item" over a single picture. The grid also guards against that independently: a group with fewer than two members is rendered as a lone file, so bad data cannot produce that card either.
A dragged group says how many it is carrying. The browser's own drag ghost is a picture of the card, which cannot say that letting go is about to move ten photos rather than one. The held card's corner badge switches from "10 photos" to Moving 10 photos, and the line above the grid reads "Carrying 10 photos. Drop them on another photo to make them all one item." Both are in-page, not the drag image:
setDragImagerenders inconsistently across browsers for an element that is not already painted, and this is feedback that has to be right every time.Joining and splitting are equally reachable. Grouping had two obvious ways in (drag a card onto another, or tick and use the bar) while splitting had only a small grey text link on the card, so the two halves of one idea were not equally discoverable. The selection bar is now driven by what the selection actually allows:
pickedEntriescounts a group as ONE thing (so a single group of four is not mistaken for four photos that could be joined to each other),canMergeispickedEntries.size > 1, andcanSplitis true when any picked photo carries agroup_key. Whichever is the only available action becomes the loudbtn-primary; when both apply, joining is primary and splitting secondary. The card's own "Split up" is a realbtn-secondary btn-smrather than grey text, and the hint above the grid teaches splitting in the same breath as joining, but only once there is a group to split.The drag says it is happening.
draggedRefis what the native drop handlers read, because a drag event needs its answer synchronously, but it is a ref and so changes nothing on the page.heldIdis the same fact as state: the card being held drops toopacity-40, every other card it could join lights withring-blue-200, and the line above the grid changes to "Now drop it on the photo you want to join it to."heldIdis set inside arequestAnimationFrame, because the browser snapshots the source element for its drag image asonDragStartreturns, and fading it synchronously would fade the ghost under the cursor too.Two things keep the drag cheap, and both matter because the card carries Framer's
layout: no drag state changes the card's box (ring, shadow and opacity all composite without a reflow, where ascalewould make Framer re-measure all nine cards on every pointer move), andonDragLeaveignores an event whoserelatedTargetis still inside the card. Without the second, crossing into any child of a card fired dragleave, so the ring flickered and the whole grid re-rendered twice per pointer move.Sent media is visibly greyer:
bg-slate-100 border-slate-300on the card andopacity-60 saturate-50on the picture, plus the lock badge and the Sent pill that were already there. Draining the colour out of the photo is what makes the state readable across a whole grid at a glance. -
components/insured/InsuredPhotoViewer.tsxis the full-screen slideshow. Its header carries a pill saying whether the photo in front of you is on its own or one item made of N photos, which is the most consequential thing about a photo and the one thing the screen used not to say. The counts come in through an optionalgroupSizesprop keyed ongroup_key, and the CALLER counts them: the media grid counts from the room's whole file list, so a search that hides three of a group's four photos cannot make the group look smaller than it is. Callers that do not deal in groups (the photos of one typed row, which are one item by definition) leave the prop out and get no pill. -
components/insured/InsuredClipPlayer.tsxplays one video or voice note full screen, with Escape and click-outside. Extracted from the media grid because the submission history needs to play the same file, and two copies of a player drift apart on how they close. -
The Submissions panel (
SubmissionsModal/SubmissionDetailinapp/insured/[token]/page.tsx) is the record of every past send. Tapping one opens it: media as tiles that open in the photo viewer or the clip player, then the typed rows as a real table.The item table is driven by the same
COLUMNSlist the live table uses, now exported fromInsuredItemTable.tsxalong withdisplayValue, so a column added there cannot go missing from the record of what was sent. It scrolls sideways rather than dropping columns, because this is the record. Two columns are added beyondCOLUMNS: Date Created and Added By.Added By is the link's own
insured_email, newly exposed onInsuredStateOut. Every row on a link came from one person, so one address answers it for the whole batch; it is the same fact the adjuster's side stores onProcessingJob.uploaded_by. Note that the state payload has an EXACT key-set guard test, so adding this field meant deciding, in that test, that the homeowner may see it. It is their own address, so they may.Two layout details are load-bearing. The panel is
max-w-6xl max-h-[88vh] flex flex-col overflow-hiddenand its body isflex-1 min-h-0 overflow-y-auto. Withoutmin-h-0a flex child will not shrink below its content, so the body spilled out of the capped panel and the whole PAGE scrolled instead of the modal.Escape unwinds one layer per press. The photo viewer and the clip player have their own
documentkeydown handlers, so a single press used to close the photo AND step the panel back out of the submission behind it. The panel now owns oneOverlayvalue ({kind:'photo'|'clip'}or null) and its handler returns early while that is set. Leaving a submission clears it, so coming back never lands on a photo they had finished with.initialOpenAtlands the panel INSIDE one submission rather than on the list, which is how the review screen's dated rows open the send they name. The back chevron still steps out to the list, so it is a shortcut and not a dead end.
Every timestamp goes out as an explicit UTC instant. The models store
naive UTC (datetime.utcnow(), and SQLite has no zone type), and Pydantic
serialised that as it was held, so the wire carried
2026-09-03T00:48:43.148433 with nothing to say which zone that was.
new Date() follows the ECMAScript rule for a date-TIME string with no offset
and reads it as LOCAL, so every stamp on this feature was shown a whole
timezone offset out: four hours into the future for a reader in New York, which
is how it was reported. UtcDatetime in app/schemas/times.py declares a naive
value to be the UTC it always was and emits a trailing Z. Reach for it in any
schema whose datetimes a person reads as a time of day; it does not move the
instant, it only says what the instant is.
The wizard prints times through lib/insuredTime.ts, one module rather than
the four near-identical copies it replaced (which had already begun to differ).
The zone is always named, because a bare "1:57 PM" invites a reader to check
it against their own clock and quietly distrust the page when it disagrees, and
an adjuster in California reading a homeowner's list has every reason to wonder
whose afternoon it was.
InsuredDraftItem.first_saved_at is what the Date Created column shows, not
created_at. created_at is stamped when the BLANK row appears, which is the
moment somebody pressed Shift+Enter: add ten rows in the morning, fill them in
at night, and every one claims to be from the morning. It is set once, the first
time a save leaves the row non-blank, and never moved again, including if they
later clear the row (that is still when they first put something there).
Quantity does not count, since it arrives as 1 and stays 1.
It is a SEPARATE column rather than a moving created_at, which has to stay
put: the submitted_at backfill compares created_at <= links.submitted_at, and
the repair migration treats submitted_at < created_at as impossible.
Rewriting it later would make both of those lie. INSURED_FIRST_SAVED_SQL
fills it from created_at for rows written before the column existed, and is
self-limiting (a filled row is no longer NULL), so it does not go through
_run_once. Rows that are still genuinely blank are left alone, because a dash
is the truth for them.
Money is written as money, as it is typed. liveMoney in
InsuredItemTable.tsx runs on every keystroke: the sign and the thousands
separators go in as each digit lands, and a THIRD decimal is refused at the
keystroke rather than accepted and then complained about. An error nobody can
type their way into is one nobody has to read. formatMoney runs on blur and
does the one remaining job, padding $1,000.5 to $1,000.50; padding live
would make "15" impossible to enter, because typing "1" would immediately
become "$1.00".
Live formatting means moving the caret, or it lands at the end of the box on
every keystroke. caretFor counts position in the characters that MEAN
something (digits and the point), because those are the only ones a typist is
aware of moving past, so inserting a digit mid-number keeps the cursor where
they put it even as a comma appears to its left.
parseMoney strips it all back off on the way in, so a value this table
formatted, or one pasted out of a receipt, is not rejected as gibberish;
validation and the "nothing changed" check both run on the parsed number. The
money branch of validateField is now only reachable through the ceiling
("That seems too high"), since the shape is enforced at the keystroke.
-
The review screen is two columns from
lgup, stacking on a phone in the same order. Left, under Sending history: the record, which is now one dated row per past send carrying who sent it, each tapping through to that submission's detail, then an About to send card stamped with now and with the same address, then the amber warning. Right, under Room overview: one card per room. Both columns are titled with one line of subtext, because two stacks of cards side by side do not say which is the record and which is the state of the rooms.Each column's heading sits OUTSIDE the spaced stack below it with its own
mb-3, because the two columns space their cards differently (space-y-4andspace-y-2.5) and a shared gap here is the only thing that keeps the first card of each level with the other. The subtext of each is one line for the same reason: a second line in one column would push its cards out of line. Measured level at 1024, 1200, 1500 and 1900px.Keep editing sits in the sticky footer to the left of Send my list. Until it existed the only way off this screen was to send, which is the one thing somebody who has changed their mind does not want to do. It is
btn-secondaryand loses its label belowsm, so the loud button stays the only thing competing for the eye. A single total could not answer "when did I send the kitchen?", which is what somebody comes back to this screen to find out, and the room summary is a different question that was competing for the same column. -
components/InviteInsuredModal.tsxis the adjuster's side, opened from Ask the Insured on the claim page.
Testing
backend/tests/test_insured_link.py. The tests lean on what makes it safe
rather than the happy path: what a token can and cannot reach, that the payload
exposes nothing extra, that expiry and revocation bite, that submit cannot spend
a credit or start the AI, that the ceilings hold, and that the webhook refuses
anything it cannot verify. Offline like the rest of the suite.