diff --git a/prisma/seed.ts b/prisma/seed.ts index 90313c7..13b6dc4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -82,8 +82,51 @@ async function main() { console.log(`Super-admin upserted: ${superAdmin.email} (${superAdmin.id})`); + // ----------------------------------------------------------------------- + // Tenant: Test ISP 2 (for cross-tenant isolation testing) + // ----------------------------------------------------------------------- + const demoTenant2 = await prisma.tenant.upsert({ + where: { slug: "test-isp-2" }, + update: {}, + create: { + name: "Test ISP 2", + slug: "test-isp-2", + ownerEmail: "admin2@demo.com", + status: TenantStatus.ACTIVE, + }, + }); + + console.log(`Tenant upserted: ${demoTenant2.name} (${demoTenant2.id})`); + + // ----------------------------------------------------------------------- + // Admin user: admin2@demo.com / admin123 + // Scoped to Test ISP 2 tenant (for cross-tenant isolation tests) + // ----------------------------------------------------------------------- + const admin2User = await prisma.user.upsert({ + where: { + email_tenantId: { + email: "admin2@demo.com", + tenantId: demoTenant2.id, + }, + }, + update: {}, + create: { + email: "admin2@demo.com", + passwordHash: adminPasswordHash, + firstName: "Demo", + lastName: "Admin2", + tenantId: demoTenant2.id, + roles: ["ADMIN"], + isActive: true, + isSuperAdmin: false, + }, + }); + + console.log(`Admin2 user upserted: ${admin2User.email} (${admin2User.id})`); + console.log("\nSeed complete. Test credentials:"); console.log(" Admin: admin@demo.com / admin123"); + console.log(" Admin2: admin2@demo.com / admin123"); console.log(" Super-admin: superadmin@netforge.com / super123"); } diff --git a/src/app/api/admin/tenants/[id]/route.ts b/src/app/api/admin/tenants/[id]/route.ts new file mode 100644 index 0000000..e06c2be --- /dev/null +++ b/src/app/api/admin/tenants/[id]/route.ts @@ -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, + }); + } +); diff --git a/src/app/api/admin/tenants/[id]/suspend/route.ts b/src/app/api/admin/tenants/[id]/suspend/route.ts new file mode 100644 index 0000000..4c687e4 --- /dev/null +++ b/src/app/api/admin/tenants/[id]/suspend/route.ts @@ -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, + }, + }); + } + } +); diff --git a/src/app/api/admin/tenants/route.ts b/src/app/api/admin/tenants/route.ts new file mode 100644 index 0000000..ef2f239 --- /dev/null +++ b/src/app/api/admin/tenants/route.ts @@ -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); +}); diff --git a/src/lib/middleware/super-admin.ts b/src/lib/middleware/super-admin.ts new file mode 100644 index 0000000..b44d4b8 --- /dev/null +++ b/src/lib/middleware/super-admin.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; + +/** + * The context passed to super-admin route handlers. + * Handlers receive the session user confirmed to be a super-admin. + */ +export interface SuperAdminContext { + user: { + id: string; + email: string; + tenantId: string | null; + roles: string[]; + isSuperAdmin: boolean; + firstName: string; + lastName: string; + }; +} + +/** + * Handler signature for super-admin route handlers. + * Receives the request and super-admin context with user info. + * Optionally receives route segment params (for dynamic routes). + */ +type SuperAdminHandler
> = (
+ req: NextRequest,
+ ctx: SuperAdminContext,
+ params?: P
+) => Promise >(
+ handler: SuperAdminHandler
+) {
+ return async function (
+ req: NextRequest,
+ routeContext?: { params: P | Promise }
+ ): Promise