Final Year Project · Legal-Tech · Multi-Tenant SaaS

Bridging the Gap Between
Citizens and Justice.

QanoonBridge is a multi-tenant legal-aid platform that matches Pakistani citizens with verified lawyers by area — built from a blank repo to a fully role-based, geofenced dashboard system as my final year graduation project.

4 Role-Based Dashboards
3 Tier Geographic Matching
100% Tenant-Isolated Data
1 Developer, End-to-End

A justice problem, not just a software problem.

Access to legal help in Pakistan is unevenly distributed. Citizens in smaller cities and districts often don't know a single lawyer who handles their kind of case, and legal aid organizations have no structured way to route the right case to the right professional — nearby, verified, and available.

I wanted my final year project to be more than a CRUD app with a login page. I wanted to build something with real architectural weight: a system that could actually be handed to a law firm or an NGO and operate as a product, not a demo. That decision shaped everything that followed — multi-tenancy instead of a single hardcoded organization, granular role-based access instead of an "admin flag," and a real geographic matching engine instead of a static lawyer directory.

"The goal wasn't to simulate a legal platform. It was to design the actual data model, access rules, and matching logic a real legal-aid organization in Pakistan would need on day one."
— Framing decision made before writing a single line of code

That meant starting with architecture decisions most student projects skip entirely: how does one platform serve many independent organizations without leaking data between them? How do four very different user types — citizens, lawyers, admins, and a super admin — share one codebase without stepping on each other's permissions? And how does "nearby" get defined precisely enough to be useful in a country where "nearby" can mean a different city, district, or province depending on where you stand?


Legal aid matching is a routing problem in disguise.

On the surface it looks simple: a citizen has a legal issue, a lawyer can help, connect them. In practice, every one of those steps has failure modes that a real platform has to design around.

Status Quo
  • Citizens search social media or ask around for "a lawyer"
  • No way to verify a lawyer's credentials before contact
  • No structured intake — details get lost between phone calls
  • Cases assigned by whoever happens to be free, not by area or specialty
  • NGOs handling similar cases across districts never see the pattern
  • No audit trail of who touched a case or when
  • Every organization builds its own spreadsheet from scratch
QanoonBridge
  • Structured case submission with category, priority, and location
  • Lawyers verified by admins before appearing to citizens
  • Geofenced matching: same city first, then district, then province
  • Admin-driven assignment with proximity + specialization ranking
  • Case aggregation groups similar cases across a region for batch handling
  • Full audit log on every sensitive action, per tenant
  • One platform, isolated per organization, ready to onboard instantly

From schema design to a working four-role platform.

I treated this like a production system from the first migration, not a prototype I'd clean up later. That meant designing the database and permission model before touching a single dashboard screen.

Phase 1 — Foundation
Multi-Tenant Schema & Environment Setup
Scaffolded the React (Vite) frontend and Node.js/Express backend as separate workspaces. Designed the PostgreSQL schema around a shared-database, tenant-column isolation strategy — every table carries a tenant_id, scoped automatically through middleware rather than trusted to individual queries. Seeded default roles, permissions, a demo tenant, and Pakistan's geographic zones (province → division → district → city).
Phase 2 — Auth & RBAC
Permission System That Lives in the Database
Built JWT-based auth with access and refresh tokens, a tenant-resolver middleware that injects tenant context on every request, and an RBAC guard that checks permissions dynamically through a roles → role_permissions → permissions mapping table — so granting a new capability to a role never requires a code deploy. Wired up protected routes on the frontend with role-aware redirects.
Phase 3 — Citizen Module
Case Submission & Location Capture
Built the citizen dashboard: a multi-step case submission form capturing category, description, priority, and location (city, district, province, optional coordinates). Added case tracking with a visual status timeline, document upload for evidence, and a nearby-lawyers view so citizens can see who's likely to pick up their case before an admin even assigns it.
Phase 4 — Lawyer Module
Verification, Service Areas & Case Pickup
Lawyers register with bar license, specialization, and experience, then sit in a "pending verification" state until an admin approves them. Built service-area registration — primary city/district/province plus optional additional cities and a max travel radius — and an available-cases view filtered by the lawyer's own coverage zone, ranked by proximity and specialization match.
Phase 5 — Admin Module
Verification Queues, Aggregation & Coverage Maps
Built the tenant-admin dashboard: lawyer verification queue, full case oversight table with bulk actions, case aggregation (grouping similar cases by region under a lead lawyer), a lawyer-distribution coverage view to spot gaps in geographic coverage, and a searchable audit-log viewer for every sensitive action taken in the tenant.
Phase 6 — Super Admin Module
Platform-Wide Tenant Management
The layer above every tenant: create and suspend organizations, manage plans, oversee users across tenants, and view platform-wide geographic analytics — which regions have citizen demand but no verified lawyer coverage yet.
Phase 7 — Polish & Hardening
Security Pass & Case Lifecycle Testing
Ran the platform through its own security checklist: bcrypt password hashing, httpOnly refresh tokens, rate-limited auth endpoints, Helmet security headers, and — most importantly — a dedicated pass verifying tenant isolation on every single query path, not just the obvious ones.

One platform, four very different experiences.

Every role sees a dashboard shaped around what they're actually trying to do — a citizen tracking a single case, a lawyer scanning nearby opportunities, an admin running a whole tenant, or a super admin watching the entire platform. Screenshots below are placeholders — drop in your real dashboard captures here for each role.

QanoonBridge sign-in screen — tenant-aware login portal
Authentication
Secure Legal Portal Sign-In
Tenant-aware login portal — the entry point for all four roles: citizen, lawyer, admin, and super admin.
QanoonBridge citizen dashboard — case tracker and nearby lawyers
Citizen
My Cases & Nearby Lawyers
Case status timeline, document uploads, and a ranked list of verified lawyers near the citizen's registered location.
QanoonBridge lawyer dashboard — available cases filtered by service area
Lawyer
Available Cases & Service Areas
Filterable case feed scoped to the lawyer's registered cities, with distance indicators and specialization match.
QanoonBridge admin dashboard — lawyer verification queue and tenant analytics
Admin
Tenant Analytics & Lawyer Verification
Pending verification queue, case oversight table, and a geographic coverage view for the whole organization.
QanoonBridge admin case aggregation view — grouped by category and region
Admin
Case Aggregation by Category & Region
Similar cases across a district grouped under a lead lawyer for coordinated, batch handling.
QanoonBridge super admin dashboard — platform-wide tenant management and analytics
Super Admin
Tenant Management & Platform Analytics
Create, suspend, and monitor every organization on the platform, plus cross-tenant geographic demand analytics.

Permissions designed at the data layer, not the UI layer.

The temptation in a student project is to hide an admin button behind an if (user.isAdmin) check and call it RBAC. QanoonBridge stores permissions as data — a role_permissions mapping table joined against granular resource.action permissions — so the same middleware enforces access identically whether the request comes from the web app, a future mobile client, or a direct API call.

PermissionCitizenLawyerAdminSuper Admin
Submit a case
Browse available cases
Verify lawyers
Aggregate cases by region
Manage tenants
View platform-wide analytics

Every table in the schema — cases, documents, comments, aggregations, audit logs — also carries its own tenant_id, so isolation isn't a single filter you can forget to add; it's structurally present at every join.


The matching engine: how "nearby" actually gets decided.

The most interesting engineering problem in this project wasn't CRUD — it was ranking. When a citizen submits a case, the system has to decide, in order, which lawyers are worth suggesting first, using imperfect and sometimes incomplete location data.

Citizen submits case (city, district, province, category)
        │
        ├── Step 1: Query lawyer_profiles
        │     WHERE service_city = case.city
        │       AND specialization ~ case.category
        │       AND verification_status = 'verified'
        │     ORDER BY rating DESC, experience_years DESC
        │
        ├── Step 2: Insufficient results? → expand to service_district
        ├── Step 3: Still insufficient? → expand to service_province
        │
        ├── Step 4: If lat/long available on both sides
        │     → Haversine distance calculation within lawyer.max_radius_km
        │
        └── Merge + rank → distance tier → specialization match
              → rating → availability → suggested to Admin
        

The tricky part wasn't the Haversine formula — it was designing for the common case where coordinates aren't available. Most citizens filing a case from a small town supply a city name, not GPS coordinates. So the ranking had to degrade gracefully through a zone hierarchy — city → district → division → province — rather than assuming precise coordinates would always exist. I modeled that as a separate geographic_zones reference table seeded with Pakistan's actual province/division/district/city structure, so text-based location matching stays reliable even without coordinates.

Lesson: Geofencing in a real-world dataset isn't a distance formula — it's a fallback chain. The formula only fires once every cheaper, more common signal has been exhausted.

A case is never just "open" or "closed."

Early versions of the schema had a three-state case status: submitted, in progress, resolved. It fell apart the moment I mapped it against how legal aid organizations actually work — cases get rejected as duplicates, get grouped with similar cases before a lawyer ever sees them individually, and sometimes get filed as part of a batch rather than resolved one by one.

Submitted → Under Review
Every case lands in a review queue before it's visible to any lawyer — filtering spam and duplicates at the source.
Validated → Assigned
Admins assign a lawyer using the geofenced suggestion list, not a random queue pick.
In Progress → Aggregated or Resolved
A case can branch: grouped into a regional aggregation for batch filing, or resolved individually by the assigned lawyer.
Filed / Closed
Aggregated cases get filed together in court; individually resolved cases get a final admin closure review.

Modeling this as a proper state machine — with explicit transition rules for who can move a case from one status to the next — meant the frontend never has to guess what actions are valid. The backend simply refuses any transition that isn't in the allowed table.


The stack behind the platform.

React (Vite)
Role-based route structure with protected routes, context-driven auth and tenant state, and a clean citizen/lawyer/admin/super-admin split.
Node.js + Express
A layered middleware pipeline: auth → tenant resolver → RBAC guard → route handler, with every request scoped before it touches the database.
PostgreSQL + PostGIS-ready schema
Tenant-column isolation, granular permission tables, and a geographic zones reference table built for Pakistan's actual administrative structure.
JWT + bcrypt
Short-lived access tokens, httpOnly refresh tokens with rotation, and bcrypt-hashed passwords at 12 salt rounds.
Custom Geofencing Engine
City → district → province fallback ranking, with optional Haversine radius calculation when coordinates are present.
Recharts + Audit Logging
Tenant and platform-level analytics dashboards backed by a structured, queryable audit log on every sensitive action.
Knex.js Zod / Joi Validation Helmet.js express-rate-limit React Router v6 React Hook Form Multer File Uploads Role-Permission Mapping Haversine Distance JWT Refresh Rotation

Where the design actually got tested.

Problem
Tenant leakage risk in joined queries. Early versions scoped tenant_id correctly on the primary table of a query but missed it on joined tables — a case-comments query, for instance, filtered cases by tenant but not the comments themselves, which is exactly the kind of gap that looks fine until a second tenant is added.
Solution
Added tenant_id directly to every dependent table (documents, comments, service areas) rather than relying on a join back to cases, and wrote a dedicated test suite that creates two tenants and asserts zero cross-visibility on every endpoint.
Problem
Geographic fallback returning empty results. A citizen in a small town with no verified lawyers registered there got an empty suggestion list, even though qualified lawyers existed one district over.
Solution
Implemented the step-down matching chain (city → district → province) as a hard requirement rather than an edge-case afterthought, with a minimum-results threshold that automatically triggers the next tier instead of surfacing an empty state.
Problem
Permission checks scattered across route handlers. Early on, individual routes each re-implemented their own "is this user allowed" logic, which meant a new permission had to be wired into multiple places by hand — and was easy to get inconsistent.
Solution
Centralized everything into a single rbacGuard(permission) middleware that reads from the database-backed role_permissions table, so adding a new permission is a seed-data change, never a code change.

Full ownership, start to finish.

System Design
Multi-tenant data model, RBAC schema, geofencing strategy, and case lifecycle state machine — designed before any UI work began.
Frontend
Four distinct role-based dashboards in React, with protected routing, shared component library, and responsive layouts.
Backend
Express API with layered middleware, JWT auth, tenant resolution, and a permission system stored entirely as data.
Database
Full PostgreSQL schema across 12+ tables including tenants, roles, permissions, cases, lawyer profiles, service areas, and audit logs.
Geofencing Engine
City/district/province fallback ranking with optional Haversine radius calculation for precise-coordinate matches.
Security
Tenant isolation testing, rate-limited auth, hashed passwords, httpOnly refresh tokens, and a full audit trail.

What building this taught me about real systems.

1. Isolation is a property of every query, not a feature.

Multi-tenancy isn't a checkbox you add once — it's a discipline you apply to every single table and every single join, forever. The moment you treat it as "done," it's the moment a leak slips through.

2. Permissions belong in data, not in code.

Hard-coded role checks feel faster to write on day one and become a liability by week three. Storing permissions as rows means the system can grow new roles and capabilities without a redeploy.

3. "Nearby" needs a fallback chain, not a formula.

Precise geolocation is the exception, not the rule, in real-world data. Designing the degraded path — city, then district, then province — mattered more than the distance math itself.

4. A workflow is a state machine, whether you draw it that way or not.

Cases don't move linearly from open to closed. Modeling the actual branches — rejected, aggregated, filed — up front saved me from a rewrite later.


Beyond the grade — a platform an NGO could actually use.

Faster Access to Legal Help
Citizens see verified, relevant lawyers within their own area instead of searching blindly.
Structured Intake
Every case arrives with category, priority, and location — no details lost between a phone call and a spreadsheet.
Scalable to Many Organizations
The multi-tenant design means a new law firm or legal-aid NGO can be onboarded without touching the codebase.
Coverage Visibility
Admins and the platform owner can see exactly where verified legal help is thin — a first step toward closing real access gaps.

A graduation project built like a product.

QanoonBridge started as a final year requirement and became the project that taught me how to think in systems — tenants, roles, permissions, and geography as first-class design decisions rather than afterthoughts bolted onto a CRUD app.

I designed the schema before the UI, the permission model before the dashboards, and the matching engine's fallback chain before its formula. That ordering — architecture first, screens second — is the single biggest difference between a project that demos well and one that could actually be handed to an organization and run.

4 Role-Based Dashboards
12+ Core Database Tables
End-to-End Design, Build & Ownership

Other systems I've built.

Each project pushed me into different engineering territory. Here's what else I've shipped — all production, all end-to-end.

Need a system designed with this level of rigor?

I work best with founders and organizations who value real architecture over quick fixes. Let's talk.