feat(01-05): super-admin API routes and middleware guard

- withSuperAdmin() HOF: checks isSuperAdmin from session, returns 401/403
- GET /api/admin/tenants: lists all tenants with userCount, subscriberCount
- GET /api/admin/tenants/[id]: single tenant detail with users list
- POST /api/admin/tenants/[id]/suspend: suspend/activate with 7-day grace
- prisma/seed.ts: add Test ISP 2 tenant and admin2@demo.com for isolation tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-04 19:04:10 +08:00
parent d25885aaeb
commit df40eae328
5 changed files with 342 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { withSuperAdmin } from "@/lib/middleware/super-admin";
import { prisma } from "@/lib/prisma";
/**
* GET /api/admin/tenants/[id]
*
* Super-admin only: Returns single tenant details including user list.
* Returns 404 if tenant not found.
*
* Response shape:
* { id, name, slug, status, ownerEmail, createdAt, users: [...] }
*/
export const GET = withSuperAdmin<{ id: string }>(
async (_req: NextRequest, _ctx, params) => {
const tenantId = params?.id;
if (!tenantId) {
return NextResponse.json({ error: "Tenant ID required" }, { status: 400 });
}
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
include: {
users: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
roles: true,
isActive: true,
createdAt: true,
},
},
_count: {
select: { users: true },
},
},
});
if (!tenant) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 });
}
return NextResponse.json({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
status: tenant.status,
ownerEmail: tenant.ownerEmail,
businessAddress: tenant.businessAddress,
contactPhone: tenant.contactPhone,
createdAt: tenant.createdAt,
updatedAt: tenant.updatedAt,
suspendedAt: tenant.suspendedAt,
gracePeriodEndsAt: tenant.gracePeriodEndsAt,
userCount: tenant._count.users,
subscriberCount: 0,
users: tenant.users,
});
}
);

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { withSuperAdmin } from "@/lib/middleware/super-admin";
import { prisma } from "@/lib/prisma";
import { TenantStatus } from "@prisma/client";
/**
* POST /api/admin/tenants/[id]/suspend
*
* Super-admin only: Suspend or activate a tenant.
*
* Request body: { action: "suspend" | "activate" }
*
* For "suspend":
* - Sets status to PENDING_SUSPENSION
* - Sets suspendedAt to now()
* - Sets gracePeriodEndsAt to 7 days from now
* - Returns message with grace period end date
*
* For "activate":
* - Sets status to ACTIVE
* - Clears suspendedAt and gracePeriodEndsAt
* - Returns confirmation message
*/
export const POST = withSuperAdmin<{ id: string }>(
async (req: NextRequest, _ctx, params) => {
const tenantId = params?.id;
if (!tenantId) {
return NextResponse.json({ error: "Tenant ID required" }, { status: 400 });
}
// Parse request body
let body: { action?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { action } = body;
if (!action || (action !== "suspend" && action !== "activate")) {
return NextResponse.json(
{ error: 'action must be "suspend" or "activate"' },
{ status: 400 }
);
}
// Check tenant exists
const existing = await prisma.tenant.findUnique({
where: { id: tenantId },
});
if (!existing) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 });
}
if (action === "suspend") {
const now = new Date();
const gracePeriodEndsAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const updated = await prisma.tenant.update({
where: { id: tenantId },
data: {
status: TenantStatus.PENDING_SUSPENSION,
suspendedAt: now,
gracePeriodEndsAt,
},
});
return NextResponse.json({
message: `Tenant suspension initiated. Grace period ends on ${updated.gracePeriodEndsAt?.toISOString()}.`,
tenant: {
id: updated.id,
status: updated.status,
suspendedAt: updated.suspendedAt,
gracePeriodEndsAt: updated.gracePeriodEndsAt,
},
});
} else {
// activate
const updated = await prisma.tenant.update({
where: { id: tenantId },
data: {
status: TenantStatus.ACTIVE,
suspendedAt: null,
gracePeriodEndsAt: null,
},
});
return NextResponse.json({
message: "Tenant activated.",
tenant: {
id: updated.id,
status: updated.status,
suspendedAt: updated.suspendedAt,
gracePeriodEndsAt: updated.gracePeriodEndsAt,
},
});
}
}
);

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { withSuperAdmin } from "@/lib/middleware/super-admin";
import { prisma } from "@/lib/prisma";
/**
* GET /api/admin/tenants
*
* Super-admin only: Lists ALL tenants on the platform (no tenant scoping).
* Returns tenant details including user count for monitoring purposes.
*
* Response shape:
* Array of { id, name, slug, status, ownerEmail, createdAt, userCount, subscriberCount }
* Sorted by createdAt descending (newest first).
*/
export const GET = withSuperAdmin(async (_req: NextRequest) => {
const tenants = await prisma.tenant.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: {
select: { users: true },
},
},
});
const result = tenants.map((tenant) => ({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
status: tenant.status,
ownerEmail: tenant.ownerEmail,
createdAt: tenant.createdAt,
updatedAt: tenant.updatedAt,
suspendedAt: tenant.suspendedAt,
gracePeriodEndsAt: tenant.gracePeriodEndsAt,
userCount: tenant._count.users,
// subscriberCount: 0 until Subscriber model is added in Phase 2
subscriberCount: 0,
}));
return NextResponse.json(result);
});