Files
MIS-Contro-Tower/app/api/org/members/route.ts
2026-01-09 00:01:04 +00:00

197 lines
5.5 KiB
TypeScript

import { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { buildInviteEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl";
import { z } from "zod";
const INVITE_DAYS = 7;
const ROLES = new Set(["OWNER", "ADMIN", "MEMBER"]);
const inviteSchema = z.object({
email: z.string().trim().min(1).max(254).email(),
role: z.string().trim().toUpperCase().optional(),
});
function canManageMembers(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
export async function GET() {
try {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const [org, members, invites] = await prisma.$transaction([
prisma.org.findUnique({
where: { id: session.orgId },
select: { id: true, name: true, slug: true },
}),
prisma.orgUser.findMany({
where: { orgId: session.orgId },
orderBy: { createdAt: "asc" },
include: {
user: { select: { id: true, email: true, name: true, isActive: true, createdAt: true } },
},
}),
prisma.orgInvite.findMany({
where: {
orgId: session.orgId,
revokedAt: null,
acceptedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
role: true,
token: true,
createdAt: true,
expiresAt: true,
},
}),
]);
const mappedMembers = members.map((m) => ({
id: m.user.id,
membershipId: m.id,
email: m.user.email,
name: m.user.name,
role: m.role,
isActive: m.user.isActive,
joinedAt: m.createdAt,
}));
return NextResponse.json({
ok: true,
org,
members: mappedMembers,
invites,
});
} catch {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(req: Request) {
try {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const membership = await prisma.orgUser.findUnique({
where: {
orgId_userId: {
orgId: session.orgId,
userId: session.userId,
},
},
select: { role: true },
});
if (!canManageMembers(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const parsed = inviteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid invite payload" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase();
const role = String(parsed.data.role || "MEMBER").toUpperCase();
if (!ROLES.has(role)) {
return NextResponse.json({ ok: false, error: "Invalid role" }, { status: 400 });
}
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
const existingMembership = await prisma.orgUser.findUnique({
where: {
orgId_userId: {
orgId: session.orgId,
userId: existingUser.id,
},
},
});
if (existingMembership) {
return NextResponse.json({ ok: false, error: "User already in org" }, { status: 409 });
}
}
const existingInvite = await prisma.orgInvite.findFirst({
where: {
orgId: session.orgId,
email,
acceptedAt: null,
revokedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
if (existingInvite) {
return NextResponse.json({ ok: true, invite: existingInvite });
}
let invite = null;
for (let i = 0; i < 3; i += 1) {
const token = randomBytes(24).toString("hex");
try {
invite = await prisma.orgInvite.create({
data: {
orgId: session.orgId,
email,
role,
token,
invitedBy: session.userId,
expiresAt: new Date(Date.now() + INVITE_DAYS * 24 * 60 * 60 * 1000),
},
});
break;
} catch (err: any) {
if (err?.code !== "P2002") throw err;
}
}
if (!invite) {
return NextResponse.json({ ok: false, error: "Failed to create invite" }, { status: 500 });
}
let emailSent = true;
let emailError: string | null = null;
try {
const org = await prisma.org.findUnique({
where: { id: session.orgId },
select: { name: true },
});
const baseUrl = getBaseUrl(req);
const inviteUrl = `${baseUrl}/invite/${invite.token}`;
const appName = "MIS Control Tower";
const content = buildInviteEmail({
appName,
orgName: org?.name || "your organization",
inviteUrl,
});
await sendEmail({
to: invite.email,
subject: content.subject,
text: content.text,
html: content.html,
});
} catch (err: any) {
emailSent = false;
emailError = err?.message || "Failed to send invite email";
}
return NextResponse.json({ ok: true, invite, emailSent, emailError });
} catch {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
}