Naddif — Project Story Inspiration Reporting waste is not the hard part. Almost every city already has a phone number, a form, or a Facebook page. What is hard is everything after the photo is sent: someone has to triage it, decide who owns it, schedule a crew, and — the step that almost always collapses — prove it was actually cleared. Naddif began from that gap. In Tunisia, where the pilot is aimed, a resident can photograph a pile of dumped waste on their street in seconds, and nothing about that act tells them whether anything will happen next. The reporting channel and the cleanup capability sit in different organisations, with no shared record between them. A report becomes a message that disappears. So the question we set out to answer was not "how do we let people report waste?" but "how do we make a single report survive all the way to a verified clear, and leave a public record that it did?" That reframing changed the whole design: the interesting object is not the report, it is the chain of custody from the resident's photo to the crew's after-photo, with a named person accountable at each handover. It also had to work in the languages people actually use. Naddif is fully bilingual Arabic and English with a real RTL layout — not a translation layer bolted on at the end. What it does A resident photographs the waste (one to three photos), drops a precise pin, and adds a description, category, approximate size and access notes. Contact details are optional and stay private. The report is published immediately and offered to every cleanup organisation — there is no moderation queue sitting between a problem and the people who can fix it. An organisation sees the report, reviews it, and accepts it with reviewed estimates for load and collection time. A coordinator then assigns a team, optionally with a specific vehicle (capacity and unit must fit the site) and a destination. Teams accept the assignment, do the work, and record before/after evidence with measured quantities. The part that matters: completion evidence must be verified by someone other than the person who submitted it, and that rule is enforced by the database, not just by the API. If the numbers do not match the reviewed estimate, the verification is rejected with a reason and the place returns to needs_info. Verified places can receive a recurrence report if the waste comes back, because a site that was cleared once is exactly the site most likely to need clearing again. Hazardous waste cannot be assigned to an ordinary crew — it needs specialist referral. And every report is visible on a public map with rounded coordinates, so the community can see what is known about its own streets. How we built it Next.js 16 (App Router) and React 19 with TypeScript in strict mode, on a single catch-all route that dispatches by pathname. Storage is SQLite through node:sqlite — raw SQL, no ORM. The dependency list is deliberately six packages long: next, react, react-dom, zod, sharp, lucide-react. Everything else is hand-written: authentication, internationalisation, the map, the route planner, the CSS. That constraint forced better decisions than adding libraries would have. Integrity lives in the database rather than only in the API layer — CHECK constraints, partial unique indexes, and triggers. Migrations are filename-versioned and discovered at startup, so adding one requires no change to the runner. Fifty-one tests cover the backend, i18n, packaging and production behaviour. Demo and live modes are completely separate: different databases, different image directories, and demo fixtures that never leak into real data. Deployment is production-shaped: a multi-stage Dockerfile, Compose with a Caddy HTTPS profile, a Netlify frontend that proxies /api/* to a persistent backend, CI, and backup and password-reset tooling. Passwords use salted scrypt; sessions are opaque tokens, hashed at rest, HttpOnly, SameSite, and Secure in production. Uploaded images are byte- and pixel-bounded, decoded, re-encoded, and stripped of embedded metadata. Challenges we ran into Removing a role is harder than not using it. We decided moderators no longer belonged in the product — every report should go straight to the organisations. Deleting the role from the type, the validation and the UI was easy. Making it genuinely unwritable was not. SQLite cannot narrow a CHECK constraint in place; ALTER TABLE only adds columns. A narrowed role IN ('resident','coordinator','team') needs a table rebuild, and DROP TABLE users performs an implicit DELETE FROM that fires the ON DELETE CASCADE on sessions and notifications — it would have silently destroyed them. SQLite's documented rebuild procedure wants foreign keys disabled, which PRAGMA foreign_keys cannot do from inside the transaction our migration runner opens. We settled on a pair of BEFORE INSERT / BEFORE UPDATE OF role triggers, which are additive, need no rebuild, and make the role genuinely impossible to write. The migration runner made triggers look impossible. node:sqlite's exec() splits a migration file on ; and only rejoins fragments whose BEGIN/END are unbalanced. A trigger written the obvious way — BEGIN ... END; with no semicolon inside the body — is read as a single balanced fragment, loses its terminator, and fails with incomplete input. One line took our suite from 51 passing tests to 17, because every test opens a store and therefore runs migrations. Terminating the trigger body's inner statement with its own ; works. We found this by probing exec() in an isolated in-memory database rather than guessing — and our first hypothesis, a semicolon inside the error message string, produced the identical error and cost us real time. A geographic rule masquerading as a permission rule. We had a coverage circle: organisations whose category matched and whose radius contained the place. It was wired as a hard 403 on both accept and assign, and it also filtered the review queue. The effect was that a newly bootstrapped organisation saw an empty queue and, for anything it could see, was told the place was "outside your organisation or service coverage". Before changing anything we reproduced it in a throwaway database with three places at 0 km, ~4 km and ~26 km from a 12 km circle, and confirmed the third was refused while the first two were accepted in the same run. Coverage is now information, not a gate — the coordinator still sees whether a place sits inside their stated area before committing, but any organisation may take any unclaimed place. The safety rule that remains is the one that should: hazardous waste is still refused. A code change is not a data migration. We made report photos public at thumbnail size so an organisation could see a place before accepting it. It had no effect on what they saw. The code only governed new uploads — every photo already stored still had its public flag at zero, so five photos in the live database were invisible. The fix was a backfill migration, not a code change. We only found it by querying the actual data instead of trusting the code path. The client/server boundary in Server Components. A value imported from a 'use client' module into a server component is a client reference, not a function. Our i18n primitives had to live in a non-client module so server code could actually call them, while the provider stayed client-side. The failure mode is silent, which is what makes it dangerous. Distance without a library. Coverage and the route planner both need real geography. Both use the haversine formula over an earth radius of 6371 km, computed with atan2 for numerical stability at small separations: \qquad d = 2R \cdot \operatorname{atan2}!\left(\sqrt{h},\ \sqrt{1-h}\right)$$

Public search deliberately rounds coordinates to three decimal places before filtering, so the bounding-box endpoint cannot be used to recover the precise location of a private report.

Arabic and RTL, properly. Right-to-left is not a stylesheet afterthought. Language lives in a cookie so the server can render <html lang dir> on the first paint, with no flash of left-to-right content, and the same snapshot is passed to the client provider so hydration matches. Directional icons mirror; text stays in its own direction. One minified stylesheet, no CSS framework.

Accomplishments that we're proud of

  • A six-dependency application where authentication, i18n, mapping, planning and styling are all hand-written and readable.
  • Integrity that the database enforces, not just the API — including the rule that you cannot verify your own cleanup evidence.
  • Genuinely bilingual Arabic/English with correct first-paint RTL and no layout flash.
  • Demo and live data that cannot contaminate each other.
  • Real organisations and real reports running on it, not just fixtures.

What we learned

  • Reproduce before you fix. The coverage bug, the trigger failure and the invisible photos were all diagnosed by building a small experiment that showed the mechanism, not by reading code and forming a theory. Two of the three had a plausible wrong answer that produced the same symptom.
  • Permission and information are different things. A rule that says "you probably cannot act here" should not be implemented as "you are not allowed to act here".
  • Data migrations are their own deliverable. Changing the rule for future rows is not the same as changing the state of existing ones, and only the second is what users notice.
  • Constraints belong in the schema — but the schema cannot always express them. That is when you reach for a trigger, and you should know why you did. What's next for Naddif
  • More organisations, more cities. Naddif is built for many organisations sharing one map, not one team with a spreadsheet. The next step is onboarding cleanup associations and municipal crews beyond the pilot's founding organisations.
  • A public impact record. Because verification is enforced end-to-end and cannot be self-signed, the platform can publish numbers worth trusting — places cleared, tonnage collected, time from report to clear — as open data for the community and for municipal accountability.
  • Field capture. Teams already record before/after evidence with measured quantities. A dedicated mobile flow would let them capture offline and sync when they are back in coverage.
  • Recurrence as a signal, not a repeat. A place that has been cleared and reported again is the most valuable data in the system. Surfacing recurrence patterns would let organisations schedule known hotspots instead of waiting for residents to report them a third time.
  • Wider language coverage. Arabic and English are live with full RTL; the i18n layer takes additional locales without structural change.

Built With

Share this project:

Updates

Submission history