Skip to content

feat(lost-found): add lost and found register with proof-based claim adjudication (fixes #275) - #280

Open
MOHITKOURAV01 wants to merge 2 commits into
Sitaram8472:mainfrom
MOHITKOURAV01:feat/issue-275-lost-found
Open

feat(lost-found): add lost and found register with proof-based claim adjudication (fixes #275)#280
MOHITKOURAV01 wants to merge 2 commits into
Sitaram8472:mainfrom
MOHITKOURAV01:feat/issue-275-lost-found

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

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-found is 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 distinguishingMarks is the whole design

The 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:

  • The field is stripped in redactFor, at serialisation time, for every non-staff viewer — not omitted by each handler, and not hidden with CSS.
  • It is excluded from the search filter. searchRegister matches 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.
  • Only staff can write it. A student registering a found item has it forced to 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. redactFor filtered plain.claims down to the viewer's own claim — correct as far as it went. But the schema also has approvedClaim and pendingClaims as virtuals, and toObject({ 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 blunt JSON.stringify(redacted).includes(...) rather than an assertion about claims.length — a structural assertion would have passed. Both virtuals are now recomputed from the filtered set, and a hasApprovedClaim boolean 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:

  • throws ALREADY_APPROVED if any claim on the item is already approved, naming who holds it
  • throws CLAIM_NOT_PENDING for a claim that has already been decided, and CLAIM_NOT_FOUND for one that is not on the item
  • rejects every other pending claim in the same operation, with a stated reason, so the register is never in a state where two people have both been told the item is theirs

recordHandover() is guarded the same way: only from matched, 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, so WRONG_RECIPIENT is a 409 rather than a note in the UI.

Retention

retentionUntil is 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 expired so 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

  • Ticket ids come from an atomic counter (findOneAndUpdate + $inc + upsert), not countDocuments() + 1, which hands the same id to two items registered at the same moment.
  • Validation runs before the counter is touched, so a rejected form does not leave a hole in the numbering.
  • High-value items require a second, specific answer on the claim form, so a claimant has to commit to details rather than restating the public listing.
  • Lifecycle runs through one moveTo() guard backed by a transition table; handed-over is terminal.
  • Mongoose 9 has dropped callback-style middleware — a hook written pre('validate', function (next) {...}) is silently skipped, and here that hook derives retentionUntil. Every hook is an async function that throws.

Pages / Components Added or Modified

  • backend/models/LostFoundItem.js — new; claim adjudication, retention, redactFor, match scoring
  • backend/controllers/lostFoundController.js — new; 14 handlers
  • backend/routes/lostFoundRoutes.js — new; mounted at /api/lost-found
  • backend/server.js — two lines to register the router
  • frontend/src/pages/LostAndFound.jsx — new; register at /lost-found, with desk controls inline for staff
  • frontend/src/App.jsx — lazy import plus the /lost-found route

Verification

  • node --check passes on every new backend file
  • npx eslint clean on the new frontend file
  • 58 assertions run directly against in-memory documents, all passing:
    • Registration — retention derived from the category, a client-supplied retentionUntil of 2099 overwritten, stationery held for less time than jewellery, a future date rejected, an unknown category rejected
    • Claims — a four-character proof rejected, a substantive one accepted
    • Single approved claim — three pending claims, one approved, the other two rejected in the same operation with a stated reason; exactly one approved; a second approval throwing ALREADY_APPROVED and naming who already holds it, with the approved claim untouched; CLAIM_NOT_PENDING and CLAIM_NOT_FOUND on the wrong targets
    • One open claim per person — a pending claim blocks a second, a rejected one does not
    • Handover — refused before matched (NOT_MATCHED), refused to the wrong person (WRONG_RECIPIENT) with the item left in matched, accepted for the approved claimant, recipient and releasing staff both recorded, handed-over terminal and not reversible to stored
    • Lifecycleregistered → handed-over and stored → matched refused; audit appended on every move
    • Redaction — staff and admin see the marks, every claim, the storage location and the audit trail; a pending claimant sees none of the marks anywhere in their serialised item, sees their own claim, and cannot see another claimant's proof text (the virtuals bug above); a browsing student sees no claims at all; an approved claimant finally sees the marks while the displaced claimant still cannot
    • Scoring — an obvious pair scores ≥ 80, an unrelated pair < 30, scores bounded at 100, a missing counterpart scores 0, and stop words are not tokenised

Checklist

  • Lint passes with no new errors
  • Claim adjudication, handover, retention and redaction exercised directly
  • Tested against a live MongoDB instance — no database credentials available to me, so the HTTP layer and the counter collection have not been run end to end
  • Responsive layout (register cards and the claim dialog reflow; the dialog scrolls within max-h-[90vh])
  • No new console errors or warnings
  • Screenshots — omitted, the page needs a seeded register to show anything

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.

…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
MOHITKOURAV01 force-pushed the feat/issue-275-lost-found branch from 1ce252a to 51a9cde Compare August 4, 2026 17:10
# Conflicts:
#	frontend/src/App.jsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Lost & Found registry with proof-based claim adjudication

1 participant