feat(lost-found): add lost and found register with proof-based claim adjudication (fixes #275) - #280
Open
MOHITKOURAV01 wants to merge 2 commits into
Open
Conversation
…adjudication Adds a searchable register for handed-in and missing items, with claims adjudicated against details the register deliberately does not publish. - distinguishingMarks are withheld from every non-staff viewer until their claim is approved, and are excluded from the search index — a listing that described the chipped hinge would tell every claimant how to pass the test - At most one claim per item can be approved; approving one rejects the rest in the same operation, with a stated reason. The rule lives in the model so a later route cannot approve around it - Handover is only possible from matched, and only to the claimant whose claim was approved - Claimants see their own claim and never another claimant's proof text - retentionUntil is derived from the category; the retention sweep flags items rather than disposing of them, and disposal is refused while a claim is outstanding - Ticket ids come from an atomic counter rather than count + 1 - Advisory match scoring pairs lost reports with found items for the desk - Register page at /lost-found with the desk controls inline for staff Closes Sitaram8472#275
MOHITKOURAV01
force-pushed
the
feat/issue-275-lost-found
branch
from
August 4, 2026 17:10
1ce252a to
51a9cde
Compare
# Conflicts: # frontend/src/App.jsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related Issue
Closes #275
Description
Adds a lost and found register: items handed in, items reported missing, and a claim process that decides between people who both say the headphones are theirs.
/lost-foundis one page with two audiences. A student searches the register, reports something, and claims an item by describing it. Desk staff get the adjudication controls inline — a separate dashboard tab would mean the person physically holding the item has to go somewhere else to log it.Withholding
distinguishingMarksis the whole designThe hard part of a lost-and-found is not storage, it is adjudication, and the only thing that makes a claim decidable is a detail the claimant could not have read off the listing.
So the register publishes the coarse fields — category, colour, brand, where and roughly when — and holds back
distinguishingMarks: the chipped hinge, the blue sticker, the name inked inside the flap. A listing that read "black earbuds, case has a chipped hinge and a blue sticker" has just told every potential claimant how to pass the test, and the claim form stops being worth filling in.Three things follow, and each is enforced somewhere a later change cannot casually undo:
redactFor, at serialisation time, for every non-staff viewer — not omitted by each handler, and not hidden with CSS.searchRegistermatches on title, description, colour, brand and location but deliberately not on the marks. A searchable secret is not a secret: matching on it would let a claimant confirm a guess without ever filing a claim.null, so a would-be claimant cannot plant their own answer.Once a claim is approved, that claimant can see the marks. The claimants whose claims were displaced still cannot.
This is where I found the bug in my own first version, and it is worth flagging because it is a shape that recurs.
redactForfilteredplain.claimsdown to the viewer's own claim — correct as far as it went. But the schema also hasapprovedClaimandpendingClaimsas virtuals, andtoObject({ virtuals: true })had already expanded both from the unfiltered array. So every other claimant's proof text shipped anyway, one key further down the same response. The check that caught it was a bluntJSON.stringify(redacted).includes(...)rather than an assertion aboutclaims.length— a structural assertion would have passed. Both virtuals are now recomputed from the filtered set, and ahasApprovedClaimboolean is exposed instead, because whether somebody has been approved is not a secret while who they are and what they wrote is.One approved claim, ever
approveClaim()lives on the model, not in the controller, so a second route added later cannot approve around it. It:ALREADY_APPROVEDif any claim on the item is already approved, naming who holds itCLAIM_NOT_PENDINGfor a claim that has already been decided, andCLAIM_NOT_FOUNDfor one that is not on the itemrecordHandover()is guarded the same way: only frommatched, only to the claimant whose claim was approved, and it records who released the item. Handing it to whoever is standing at the desk is the exact failure the claim process exists to prevent, soWRONG_RECIPIENTis a409rather than a note in the UI.Retention
retentionUntilis derived from the category — a pencil case is held 30 days, a ring 365 — and is never accepted from the client, because a reporter who could set it would park a single glove in the cupboard until 2031.The retention sweep flags, it does not dispose. It moves eligible items to
expiredso the desk has a list to work from. A sweep that threw things away on a timer would eventually throw away somebody's passport. Disposal is a separate, deliberate action, and it is refused outright while any claim is outstanding.Match scoring
A pure function scoring a lost report against a found item on category, colour, brand, date proximity and word overlap. Deliberately advisory: it sorts the desk's work so likely pairs surface instead of being scrolled past, and nothing in the model reads it. Adjudication stays with a human comparing what the claimant wrote down unprompted.
Smaller things
findOneAndUpdate+$inc+upsert), notcountDocuments() + 1, which hands the same id to two items registered at the same moment.moveTo()guard backed by a transition table;handed-overis terminal.pre('validate', function (next) {...})is silently skipped, and here that hook derivesretentionUntil. Every hook is anasyncfunction that throws.Pages / Components Added or Modified
backend/models/LostFoundItem.js— new; claim adjudication, retention,redactFor, match scoringbackend/controllers/lostFoundController.js— new; 14 handlersbackend/routes/lostFoundRoutes.js— new; mounted at/api/lost-foundbackend/server.js— two lines to register the routerfrontend/src/pages/LostAndFound.jsx— new; register at/lost-found, with desk controls inline for stafffrontend/src/App.jsx— lazy import plus the/lost-foundrouteVerification
node --checkpasses on every new backend filenpx eslintclean on the new frontend fileretentionUntilof 2099 overwritten, stationery held for less time than jewellery, a future date rejected, an unknown category rejectedALREADY_APPROVEDand naming who already holds it, with the approved claim untouched;CLAIM_NOT_PENDINGandCLAIM_NOT_FOUNDon the wrong targetsmatched(NOT_MATCHED), refused to the wrong person (WRONG_RECIPIENT) with the item left inmatched, accepted for the approved claimant, recipient and releasing staff both recorded,handed-overterminal and not reversible tostoredregistered → handed-overandstored → matchedrefused; audit appended on every moveChecklist
max-h-[90vh])Notes
Purely additive. The retention sweep is exposed as an endpoint for the desk to run on demand and is written to be driven from
backend/scheduler/later; I have not wired it in here to keep the diff to one concern, and would add it in this PR if preferred.