Skip to main content
Asif.
← All Articles
Next.jsMicroservicesPrismaPostgreSQLCivic TechBangladeshPersonal Project

Building Nagorik: A Civic Issue Reporting Platform for Bangladesh (6 Microservices, Anonymous Reports, and a Government Dashboard)

Jan 202513 min readAsif Ahsan

Bangladesh has a pothole problem. It also has a garbage problem, a broken streetlight problem, and a drainage problem. What it doesn't have is a simple way for citizens to report these issues and actually track whether anything gets done. Nagorik is my attempt to build that — a civic issue reporting platform with anonymous submissions, trackable codes, a location hierarchy, and a government admin dashboard.

The idea for Nagorik came from a frustrating personal experience. A road near my house had a pothole the size of a dinner plate for eight months. There was nowhere obvious to report it — no app, no number that felt real, no way to know if anyone would ever see the complaint. So I built the platform I wished existed.

Nagorik (Bengali for 'citizen') is a civic issue reporting platform. Citizens report local problems — potholes, garbage dumps, broken streetlights, waterlogging, damaged infrastructure — with photos, location, and a description. Each report gets a unique tracking code. Government admin staff verify, assign, and resolve reports through a dedicated dashboard. No account required to submit. That last part turned out to be the most interesting design constraint of the whole project.

The Anonymous Reporting Problem

Requiring account creation before submitting a report kills participation. It's friction at exactly the wrong moment — when someone has just spotted a problem and wants to report it before they forget. But anonymous submissions create their own problems: spam, duplicate reports, and no way to notify submitters of updates.

My solution: every report — whether from a registered user or a guest — gets a unique tracking code in the format NAG-YYYY-XXXXXX, where YYYY is the year and XXXXXX is a zero-padded sequential integer. Anyone with this code can look up the current status of their report without logging in.

typescript
// issues service — report creation with tracking code generation
async function createIssue(dto: CreateIssueDto, userId?: string): Promise<Issue> {
    const year = new Date().getFullYear();

    // Atomic sequence per year — no gaps, no race conditions
    const sequence = await prisma.$queryRaw<[{ nextval: bigint }]>`
        SELECT nextval('issue_sequence_${year}')
    `;
    const seq = Number(sequence[0].nextval);
    const trackingCode = `NAG-${year}-${String(seq).padStart(6, "0")}`;

    return prisma.issue.create({
        data: {
            trackingCode,
            title: dto.title,
            description: dto.description,
            categoryId: dto.categoryId,
            locationId: dto.locationId,
            status: "pending",
            isAnonymous: !userId,
            submittedById: userId ?? null,
            photoKeys: dto.photoKeys,  // S3 object keys
        },
    });
}

Using a PostgreSQL sequence per year (issue_sequence_2025, issue_sequence_2026) guarantees no duplicate codes even under concurrent load, and resets the counter cleanly at year boundaries — making the tracking codes more readable: NAG-2025-000001.

The Architecture: 6 Microservices

Nagorik runs as a monorepo with six backend services, a shared Prisma schema package, and a Next.js 16 frontend. All services communicate through a central API gateway that handles routing and JWT verification so individual services stay stateless.

  • api-gateway:3001 — Request routing, JWT verification, rate limiting
  • auth:3002 — Phone OTP auth (SMS via Twilio), JWT issuance, refresh tokens
  • users:3003 — Profiles, notification preferences, report history
  • issues:3004 — Core report CRUD, status lifecycle, upvoting, search
  • locations:3005 — Division / District / Upazila hierarchy with geo data
  • upload:3006 — S3 presigned URL generation, photo validation, virus scan hooks

The monorepo approach with a shared Prisma schema was a deliberate tradeoff. Each service owns its domain, but they all reference the same schema package — which means schema changes require a coordinated migration, not independent deploys. For a solo project this is manageable; for a large team it would need more thought.

typescript
// packages/database/schema.prisma — shared across all services
model Issue {
    id           String      @id @default(cuid())
    trackingCode String      @unique
    title        String
    description  String
    status       IssueStatus @default(pending)
    isAnonymous  Boolean     @default(true)
    upvoteCount  Int         @default(0)
    photoKeys    String[]
    categoryId   String
    locationId   String
    submittedById String?

    category  Category  @relation(fields: [categoryId], references: [id])
    location  Location  @relation(fields: [locationId], references: [id])
    upvotes   Upvote[]
    comments  Comment[]
    history   IssueHistory[]

    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
}

enum IssueStatus {
    pending
    verified
    assigned
    in_progress
    resolved
    closed
}

The Location Hierarchy

Bangladesh has a well-defined administrative hierarchy: 8 Divisions → 64 Districts → 495 Upazilas → thousands of Union Parishads. For civic reporting, Division/District/Upazila is the right granularity — specific enough to route reports to the right authority, broad enough not to overwhelm the location picker.

The locations service preloads all 495 Upazilas into memory on startup (the dataset is static, ~150KB of JSON). The frontend cascades selection: pick Division → Districts filter → pick District → Upazilas filter. No round-trips after the initial load. This matters on Bangladesh's mobile connections where a round-trip adds 200–400ms.

typescript
// locations service — cascading hierarchy endpoint
app.get("/hierarchy", async (req, res) => {
    // Served from in-memory cache — static data changes ~once per decade
    const data = locationCache.getAll();

    const hierarchy = data.divisions.map(division => ({
        ...division,
        districts: data.districts
            .filter(d => d.divisionId === division.id)
            .map(district => ({
                ...district,
                upazilas: data.upazilas.filter(u => u.districtId === district.id),
            })),
    }));

    res.json(hierarchy);
});

// Client-side: one fetch, then filter locally
const [hierarchy] = useState(() => fetch("/api/locations/hierarchy").then(r => r.json()));

const filteredDistricts = useMemo(
    () => hierarchy?.find(d => d.id === selectedDivision)?.districts ?? [],
    [hierarchy, selectedDivision]
);

Photo Uploads with S3 Presigned URLs

Citizens can attach up to 5 photos to a report. Uploading images through the API server wastes bandwidth and ties up server resources — the standard solution is S3 presigned URLs: the client gets a time-limited URL and uploads directly to S3, bypassing the server entirely.

typescript
// upload service — presigned URL generation
app.post("/presigned", authenticateOptional, async (req, res) => {
    const { count, mimeTypes } = req.body;

    if (count > 5) return res.status(400).json({ error: "Max 5 photos per report" });

    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (mimeTypes.some((t: string) => !allowed.includes(t))) {
        return res.status(400).json({ error: "Only JPEG, PNG, and WebP allowed" });
    }

    const uploads = await Promise.all(
        mimeTypes.map(async (mimeType: string) => {
            const key = `issues/pending/${crypto.randomUUID()}`;
            const url = await getSignedUrl(
                s3,
                new PutObjectCommand({
                    Bucket: process.env.S3_BUCKET,
                    Key: key,
                    ContentType: mimeType,
                    ContentLength: 10 * 1024 * 1024, // 10MB max
                }),
                { expiresIn: 300 } // 5 minutes
            );
            return { key, url };
        })
    );

    res.json({ uploads });
});

The Issue Lifecycle

The issue lifecycle was the hardest thing to get right — not technically, but from a product perspective. The states had to be meaningful to citizens checking their tracking code, not just convenient for admin staff.

  • pending — Submitted, awaiting admin review
  • verified — Admin confirmed it's a real issue (not spam/duplicate)
  • assigned — Routed to the relevant department or field team
  • in_progress — Active work has started (admin confirms on-site activity)
  • resolved — Fix confirmed complete by admin
  • closed — Resolved and archived, or rejected as out-of-scope

Every status transition is logged to an IssueHistory table with the admin user ID, timestamp, and optional note. Citizens tracking their report see a clean timeline of status changes — not just the current state, but the full history of what happened and when.

The Government Admin Dashboard

The admin dashboard is a separate Next.js app (same monorepo) that government staff use to triage, assign, and resolve reports. It has role-based access: super-admins can see all reports nationally, district admins see only their district, upazila staff see only their upazila.

Analytics are built with Recharts — resolution rate over time, average time-to-resolve by category, most-reported locations heatmap, and an upvote-weighted priority queue that surfaces the issues citizens care most about.

typescript
// issues service — upvote-weighted priority score
// Surfaces actively-complained-about issues over old forgotten ones
async function getPriorityQueue(locationId: string) {
    return prisma.$queryRaw`
        SELECT
            i.*,
            -- Decay older upvotes, boost recent ones
            (i."upvoteCount" * EXP(-0.1 * EXTRACT(DAY FROM NOW() - i."createdAt"))) AS priority_score
        FROM "Issue" i
        WHERE i."locationId" = ${locationId}
          AND i.status IN ('pending', 'verified')
        ORDER BY priority_score DESC
        LIMIT 50
    `;
}

Upvoting Without Login

The upvote feature lets citizens signal that a reported issue affects them too. But requiring login to upvote defeats the purpose — most people won't bother. The solution: device fingerprinting (screen resolution + timezone + user agent hash) as an anti-abuse mechanism, stored as a short hash cookie. Not perfect, but good enough to prevent trivial abuse without friction.

typescript
// Lightweight device fingerprint for anonymous upvote deduplication
function getDeviceFingerprint(req: Request): string {
    const ua = req.headers["user-agent"] ?? "";
    const lang = req.headers["accept-language"] ?? "";
    const ip = req.ip ?? "";
    // One-way hash — not reversible, just an identifier
    return crypto
        .createHash("sha256")
        .update(`${ua}|${lang}|${ip}`)
        .digest("hex")
        .slice(0, 16);
}

What I'd Do Differently

The shared Prisma schema was convenient but created coupling I'd avoid in production. The right move would be database-per-service with async events (Kafka or at minimum a transactional outbox) for cross-service data. For a solo project the pragmatic shortcut is fine; for a real government deployment it would be a maintenance headache.

The other thing I underestimated: the location data problem. Getting accurate, complete Division/District/Upazila data for Bangladesh required significant cleanup — transliterations of Bangla names, merging multiple sources, handling special administrative zones (Dhaka City Corporation vs Dhaka District). Geographic data for emerging markets is messy in ways that geographic data for the US is not.

The most satisfying part of building civic tech: the problem is completely real, the users are real citizens, and a working solution would make a tangible difference. E-commerce is fun to build. Civic infrastructure feels important.

A
Asif Ahsan
Senior Software Engineer · Dhaka, Bangladesh

Full-stack engineer with 8+ years building scalable products across web, mobile, and cloud. Currently building Gunti, Nexus RTC, and Nagorik.