+
Showing {page * PAGE + 1}–{Math.min((page + 1) * PAGE, total)} of {total}
setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
- className="px-4 py-2 rounded-xl border border-white/20 text-sm disabled:opacity-40 hover:bg-white/10">
- ← Prev
+ className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">
+ ← Prev
setPage((p) => p + 1)} disabled={(page + 1) * PAGE >= total}
- className="px-4 py-2 rounded-xl border border-white/20 text-sm disabled:opacity-40 hover:bg-white/10">
+ className="px-4 py-2 rounded-xl border border-slate-200 text-slate-600 text-sm disabled:opacity-40 hover:bg-slate-50">
Next →
diff --git a/app/api/lga/register/route.ts b/app/api/lga/register/route.ts
index c770a98..e671306 100644
--- a/app/api/lga/register/route.ts
+++ b/app/api/lga/register/route.ts
@@ -6,6 +6,16 @@ import { lgaSignUpSchema } from "@/lib/validations";
import { generateToken, sanitizeInput } from "@/lib/utils";
import { sendLGAVerificationEmail } from "@/lib/email";
+// Nigeria has exactly 774 Local Government Areas. Once /api/admin/seed has
+// loaded the official 774 (see prisma/seeds/nigeria-lgas.ts), every real
+// registration matches a seeded row by name+state and goes through the
+// "claim" path below (an update, not a create) — so this only ever blocks
+// the "fresh create" branch, which is where anything that isn't one of the
+// 774 official LGAs would otherwise slip in. Test/seed scripts can bypass
+// this with the same secret that guards /api/admin/seed.
+const TOTAL_LGA_CAP = 774;
+const SEED_SECRET = process.env.SEED_SECRET ?? "";
+
export async function POST(request: Request) {
// Rate limit: 3 per hour per IP
const ip = getClientIP(request);
@@ -108,6 +118,20 @@ export async function POST(request: Request) {
include: { chairman: { select: { id: true } } },
});
} else {
+ // No seed record — this would create an LGA that isn't one of
+ // Nigeria's official 774. Blocked unless the request carries the
+ // seed-secret test bypass.
+ const isTestBypass = Boolean(SEED_SECRET) && request.headers.get("x-seed-secret") === SEED_SECRET;
+ if (!isTestBypass) {
+ const totalLgas = await db.lGA.count();
+ if (totalLgas >= TOTAL_LGA_CAP) {
+ return NextResponse.json(
+ { error: "Nigeria has 774 Local Government Areas and that limit has been reached. If your LGA isn't listed, contact support." },
+ { status: 409 }
+ );
+ }
+ }
+
// No seed record — create fresh
lga = await db.lGA.create({
data: {
diff --git a/playwright.config.ts b/playwright.config.ts
index 35c0ae6..101a65b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,4 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
+import { loadEnvConfig } from "@next/env";
+
+// Load .env.local the same way `next dev` does, so tests can read
+// SEED_SECRET (and anything else app code reads from process.env).
+loadEnvConfig(process.cwd());
export default defineConfig({
testDir: "./tests/e2e",
diff --git a/tests/e2e/admin-browser.spec.ts b/tests/e2e/admin-browser.spec.ts
index c041571..35bc8ac 100644
--- a/tests/e2e/admin-browser.spec.ts
+++ b/tests/e2e/admin-browser.spec.ts
@@ -65,7 +65,7 @@ async function seedPendingLGA(request: APIRequestContext, ip: string): Promise<{
const email = `chairman_adm_${suffix}@example.com`;
const reg = await request.post("/api/lga/register", {
- headers: { "x-forwarded-for": ip },
+ headers: { "x-forwarded-for": ip, "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName, state: "Kano", chairmanName: "Alhaji Admin",
email, phone: "08011223344", officeAddress: "3 Council Close, Kano",
diff --git a/tests/e2e/admin-lga-ward-e2e.spec.ts b/tests/e2e/admin-lga-ward-e2e.spec.ts
index d4488d0..e61b210 100644
--- a/tests/e2e/admin-lga-ward-e2e.spec.ts
+++ b/tests/e2e/admin-lga-ward-e2e.spec.ts
@@ -53,6 +53,7 @@ async function seedApprovedLGA(ip: string): Promise<{ id: string; email: string;
const ctx = await apiRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { "x-forwarded-for": ip } });
const reg = await ctx.post("/api/lga/register", {
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName, state, chairmanName: "Chief Record", email, phone: "08012345678",
officeAddress: "1 Record Road, Ikeja", sectors: ["Health"],
diff --git a/tests/e2e/auth-e2e.spec.ts b/tests/e2e/auth-e2e.spec.ts
index 08c487a..4df06c7 100644
--- a/tests/e2e/auth-e2e.spec.ts
+++ b/tests/e2e/auth-e2e.spec.ts
@@ -263,7 +263,10 @@ test.describe("Auth E2E — LGA Chairman", () => {
test("registration → 201 with lgaId", async () => {
const ctx = await ctxForIp(IP);
- const res = await ctx.post("/api/lga/register", { data: registrationPayload });
+ const res = await ctx.post("/api/lga/register", {
+ data: registrationPayload,
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
+ });
expect(res.status()).toBe(201);
const body = await res.json();
expect(body.success).toBe(true);
@@ -309,6 +312,7 @@ test.describe("Auth E2E — LGA Chairman", () => {
const ctx = await ctxForIp(IP);
const res = await ctx.post("/api/lga/register", {
data: { ...registrationPayload, lgaName: `${lgaName} Two` },
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
});
expect(res.status()).toBe(409);
});
diff --git a/tests/e2e/chairman-crud-e2e.spec.ts b/tests/e2e/chairman-crud-e2e.spec.ts
index 438f92e..56015b6 100644
--- a/tests/e2e/chairman-crud-e2e.spec.ts
+++ b/tests/e2e/chairman-crud-e2e.spec.ts
@@ -64,6 +64,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s
const ctx = await ctxForIp(ip);
const reg = await ctx.post("/api/lga/register", {
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName: `Governville ${suffix}`, state: "Lagos", chairmanName: "Chief Govern",
email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja",
diff --git a/tests/e2e/citizen-lga-e2e.spec.ts b/tests/e2e/citizen-lga-e2e.spec.ts
index af256cb..3836fc9 100644
--- a/tests/e2e/citizen-lga-e2e.spec.ts
+++ b/tests/e2e/citizen-lga-e2e.spec.ts
@@ -120,6 +120,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s
const ctx = await ctxForIp(ip);
const reg = await ctx.post("/api/lga/register", {
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName, state: "Lagos", chairmanName: "Chief Portal", email, phone: "08012345678",
officeAddress: "1 Council Road, Ikeja", sectors: ["Health", "Education"],
diff --git a/tests/e2e/fr01-fr03.spec.ts b/tests/e2e/fr01-fr03.spec.ts
index 4a04b9c..8f5d81e 100644
--- a/tests/e2e/fr01-fr03.spec.ts
+++ b/tests/e2e/fr01-fr03.spec.ts
@@ -10,8 +10,11 @@
*/
import { test, expect, request as apiRequest } from "@playwright/test";
+import { Pool } from "pg";
const BASE = "http://localhost:3000";
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+test.afterAll(async () => { await pool.end(); });
async function apiPost(
url: string,
@@ -37,6 +40,10 @@ async function apiGet(url: string, headers: Record
= {}) {
const ADMIN = { "x-admin-secret": process.env.NEXT_PUBLIC_ADMIN_SECRET ?? "4a0423d4888f73e76fbbb5655ac5458c09be34d5d4eaa9522f943b9cc3d80666" };
+// Bypasses the 774-LGA total cap on POST /api/lga/register (see route.ts) —
+// the local test DB accumulates far more than 774 rows across test runs.
+const SEED_HEADER = { "x-seed-secret": process.env.SEED_SECRET ?? "" };
+
// ─── FR-01-01: Citizen Registration ─────────────────────────────────────────
test.describe("FR-01-01: Citizen Registration — API", () => {
@@ -272,7 +279,7 @@ test.describe("FR-02-01: LGA Registration — API contracts", () => {
password: "Secure@123",
confirmPassword: "Secure@123",
terms: true,
- });
+ }, SEED_HEADER);
expect([201, 429]).toContain(status);
if (status === 201) {
expect(body.success).toBe(true);
@@ -294,11 +301,11 @@ test.describe("FR-02-01: LGA Registration — API contracts", () => {
confirmPassword: "Secure@123",
terms: true,
};
- await apiPost("/api/lga/register", { ...base, email: `lga1+${ts}@mailinator.com` });
+ await apiPost("/api/lga/register", { ...base, email: `lga1+${ts}@mailinator.com` }, SEED_HEADER);
const { status } = await apiPost("/api/lga/register", {
...base,
email: `lga2+${ts}@mailinator.com`,
- });
+ }, SEED_HEADER);
// 409 on dup, or 429 if rate-limited (3 per hour)
expect([409, 429]).toContain(status);
});
@@ -314,12 +321,79 @@ test.describe("FR-02-01: LGA Registration — API contracts", () => {
password: "Secure@123",
confirmPassword: "Secure@123",
terms: true,
- });
+ }, SEED_HEADER);
expect([400, 429]).toContain(status);
if (status === 400) expect(typeof body.error).toBe("string");
});
});
+// ─── 774-LGA total cap guard ────────────────────────────────────────────────
+
+test.describe("774-LGA cap guard on POST /api/lga/register", () => {
+ test("a fresh LGA create is blocked once the total reaches 774, but the seed-secret bypass still works", async () => {
+ const { rows } = await pool.query("SELECT count(*)::int AS count FROM lgas");
+ test.skip(rows[0].count < 774, "local DB has fewer than 774 LGAs; the cap hasn't kicked in yet");
+
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE,
+ extraHTTPHeaders: { "x-forwarded-for": `198.30.${Math.floor(Math.random() * 254) + 1}.1` },
+ });
+ const ts = Date.now();
+ const payload = (suffix: string) => ({
+ lgaName: `Cap Guard Test ${ts}${suffix}`,
+ state: "Lagos",
+ chairmanName: "Cap Guard Tester",
+ email: `cap-guard-${ts}${suffix}@mailinator.com`,
+ phone: "08012345678",
+ officeAddress: "1 Cap Guard Road",
+ sectors: ["Health"],
+ password: "Secure@123",
+ confirmPassword: "Secure@123",
+ terms: true,
+ });
+
+ const blocked = await ctx.post("/api/lga/register", { data: payload("a") });
+ expect(blocked.status()).toBe(409);
+ expect((await blocked.json()).error).toMatch(/774/);
+
+ const bypassed = await ctx.post("/api/lga/register", {
+ data: payload("b"),
+ headers: SEED_HEADER,
+ });
+ expect(bypassed.status()).toBe(201);
+ });
+
+ test("claiming an already-seeded LGA (no chairman yet) is never blocked by the cap", async () => {
+ const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
+ const lgaName = `Seed Claim Test ${suffix}`;
+ await pool.query(
+ `INSERT INTO lgas (id, "lgaName", state, "chairmanName", email, phone, "officeAddress", status, "isVerified", sectors, "createdAt", "updatedAt")
+ VALUES ($1, $2, 'Ogun', 'Vacant', $3, '08000000000', 'N/A', 'APPROVED', true, ARRAY[]::text[], now(), now())`,
+ [`captest${suffix}`.slice(0, 25), lgaName, `vacant-${suffix}@lga.gov.ng`]
+ );
+
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE,
+ extraHTTPHeaders: { "x-forwarded-for": `198.31.${Math.floor(Math.random() * 254) + 1}.1` },
+ });
+ const claim = await ctx.post("/api/lga/register", {
+ data: {
+ lgaName,
+ state: "Ogun",
+ chairmanName: "Real Chairman",
+ email: `real-chairman-${suffix}@mailinator.com`,
+ phone: "08012345678",
+ officeAddress: "1 Real Street",
+ sectors: ["Health"],
+ password: "Secure@123",
+ confirmPassword: "Secure@123",
+ terms: true,
+ },
+ });
+ expect(claim.status()).toBe(201);
+ });
+});
+
// ─── FR-02-03: Admin LGA Approval ───────────────────────────────────────────
test.describe("FR-02-03: Admin Approval — API contracts", () => {
diff --git a/tests/e2e/investor-browser.spec.ts b/tests/e2e/investor-browser.spec.ts
index 6d5c3f7..2831185 100644
--- a/tests/e2e/investor-browser.spec.ts
+++ b/tests/e2e/investor-browser.spec.ts
@@ -68,7 +68,7 @@ async function seedApprovedLGAWithEndowment(
// 1. Register
const reg = await request.post("/api/lga/register", {
- headers: { "x-forwarded-for": ip },
+ headers: { "x-forwarded-for": ip, "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName, state: "Lagos", chairmanName: "Chief Investor",
email, phone: "08012345678", officeAddress: "2 Investment Road, Ikeja",
diff --git a/tests/e2e/investor-lga-e2e.spec.ts b/tests/e2e/investor-lga-e2e.spec.ts
index b5c71dd..72ec814 100644
--- a/tests/e2e/investor-lga-e2e.spec.ts
+++ b/tests/e2e/investor-lga-e2e.spec.ts
@@ -69,6 +69,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s
const ctx = await ctxForIp(ip);
const reg = await ctx.post("/api/lga/register", {
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName: `Endowville ${suffix}`, state: "Lagos", chairmanName: "Chief Invest",
email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja",
diff --git a/tests/e2e/lga-dashboard-browser.spec.ts b/tests/e2e/lga-dashboard-browser.spec.ts
index 92343db..e97666c 100644
--- a/tests/e2e/lga-dashboard-browser.spec.ts
+++ b/tests/e2e/lga-dashboard-browser.spec.ts
@@ -147,7 +147,7 @@ async function seedVerifiedLGA(
const suffix = uniq();
const email = `chairman_${suffix}@example.com`;
const reg = await request.post("/api/lga/register", {
- headers: { "x-forwarded-for": ip },
+ headers: { "x-forwarded-for": ip, "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName: `Browserville ${suffix}`, state: "Lagos", chairmanName: "Chief Browser",
email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja",
diff --git a/tests/e2e/lga-portal-auth-e2e.spec.ts b/tests/e2e/lga-portal-auth-e2e.spec.ts
index dd0c82e..b2f840d 100644
--- a/tests/e2e/lga-portal-auth-e2e.spec.ts
+++ b/tests/e2e/lga-portal-auth-e2e.spec.ts
@@ -64,6 +64,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s
const ctx = await ctxForIp(ip);
const reg = await ctx.post("/api/lga/register", {
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName: `Portalville ${suffix}`, state: "Lagos", chairmanName: "Chief Portal",
email, phone: "08012345678", officeAddress: "1 Council Road, Ikeja",
diff --git a/tests/e2e/payments-e2e.spec.ts b/tests/e2e/payments-e2e.spec.ts
index 90a8aec..bc41e43 100644
--- a/tests/e2e/payments-e2e.spec.ts
+++ b/tests/e2e/payments-e2e.spec.ts
@@ -64,6 +64,7 @@ async function authedLGA(ip: string): Promise<{ ctx: APIRequestContext; lgaId: s
const c = await apiRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { "x-forwarded-for": ip } });
const reg = await c.post("/api/lga/register", {
+ headers: { "x-seed-secret": process.env.SEED_SECRET ?? "" },
data: {
lgaName: `Payville ${suffix}`, state: "Lagos", chairmanName: "Chief Pay",
email, phone: "08012345678", officeAddress: "1 Pay Road, Ikeja",