From 20bc4cbb03fcc3b6ec41edd0dd6a7de66cb06b72 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 15 Apr 2026 14:47:15 -0500 Subject: [PATCH] Add missing org API routes and fix registration gate - Create /api/org (GET details, PUT update) - Create /api/org/members (GET list) - Create /api/org/invitations (GET list, POST create) - Create /api/org/locations (GET list with collection days, POST create) - Fix registration: check org count instead of user count so a fresh domain setup works even if orphaned users exist from a prior AUTH_SECRET rotation Made-with: Cursor --- src/app/api/auth/register/route.ts | 4 +- src/app/api/org/invitations/route.ts | 112 +++++++++++++++++++++++++++ src/app/api/org/locations/route.ts | 83 ++++++++++++++++++++ src/app/api/org/members/route.ts | 30 +++++++ src/app/api/org/route.ts | 88 +++++++++++++++++++++ 5 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 src/app/api/org/invitations/route.ts create mode 100644 src/app/api/org/locations/route.ts create mode 100644 src/app/api/org/members/route.ts create mode 100644 src/app/api/org/route.ts diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 0e98cc9..1e19852 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -57,8 +57,8 @@ export async function POST(req: NextRequest) { role = invitation.role; organizationId = invitation.organizationId; } else { - const userCount = await prisma.user.count(); - if (userCount > 0) { + const orgCount = await prisma.organization.count(); + if (orgCount > 0) { return NextResponse.json( { error: "Registration requires an invitation" }, { status: 403 } diff --git a/src/app/api/org/invitations/route.ts b/src/app/api/org/invitations/route.ts new file mode 100644 index 0000000..a4f07a1 --- /dev/null +++ b/src/app/api/org/invitations/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const invitations = await prisma.invitation.findMany({ + where: { organizationId: session.user.orgId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + email: true, + role: true, + expiresAt: true, + acceptedAt: true, + }, + }); + + return NextResponse.json({ invitations }); + } catch (error) { + console.error("[org/invitations] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch invitations" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const membership = await prisma.orgMember.findFirst({ + where: { + userId: session.user.id, + organizationId: session.user.orgId, + }, + }); + + if (!membership || !["owner", "admin"].includes(membership.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { email, role } = await req.json(); + + if (!email) { + return NextResponse.json( + { error: "Email is required" }, + { status: 400 } + ); + } + + const existing = await prisma.invitation.findFirst({ + where: { + email, + organizationId: session.user.orgId, + acceptedAt: null, + expiresAt: { gt: new Date() }, + }, + }); + + if (existing) { + return NextResponse.json( + { error: "An active invitation already exists for this email" }, + { status: 409 } + ); + } + + const existingMember = await prisma.orgMember.findFirst({ + where: { + organizationId: session.user.orgId, + user: { email }, + }, + }); + + if (existingMember) { + return NextResponse.json( + { error: "This person is already a member" }, + { status: 409 } + ); + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + await prisma.invitation.create({ + data: { + email, + role: role || "viewer", + organizationId: session.user.orgId, + invitedById: session.user.id, + expiresAt, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[org/invitations] POST error:", error); + return NextResponse.json( + { error: "Failed to create invitation" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/org/locations/route.ts b/src/app/api/org/locations/route.ts new file mode 100644 index 0000000..894704b --- /dev/null +++ b/src/app/api/org/locations/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const locations = await prisma.location.findMany({ + where: { organizationId: session.user.orgId }, + include: { + collectionDays: { + select: { + id: true, + name: true, + dayOfWeek: true, + timeStart: true, + timeEnd: true, + isActive: true, + }, + orderBy: { dayOfWeek: "asc" }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ locations }); + } catch (error) { + console.error("[org/locations] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch locations" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const membership = await prisma.orgMember.findFirst({ + where: { + userId: session.user.id, + organizationId: session.user.orgId, + }, + }); + + if (!membership || !["owner", "admin", "editor"].includes(membership.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { name, address } = await req.json(); + + if (!name) { + return NextResponse.json( + { error: "Location name is required" }, + { status: 400 } + ); + } + + const location = await prisma.location.create({ + data: { + name, + address: address || null, + organizationId: session.user.orgId, + }, + }); + + return NextResponse.json({ location }); + } catch (error) { + console.error("[org/locations] POST error:", error); + return NextResponse.json( + { error: "Failed to create location" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/org/members/route.ts b/src/app/api/org/members/route.ts new file mode 100644 index 0000000..3ed8841 --- /dev/null +++ b/src/app/api/org/members/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const members = await prisma.orgMember.findMany({ + where: { organizationId: session.user.orgId }, + include: { + user: { + select: { id: true, email: true, displayName: true }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json({ members }); + } catch (error) { + console.error("[org/members] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch members" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/org/route.ts b/src/app/api/org/route.ts new file mode 100644 index 0000000..2fd7fb7 --- /dev/null +++ b/src/app/api/org/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const organization = await prisma.organization.findUnique({ + where: { id: session.user.orgId }, + select: { + id: true, + name: true, + slug: true, + type: true, + timezone: true, + }, + }); + + if (!organization) { + return NextResponse.json( + { error: "Organization not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ organization }); + } catch (error) { + console.error("[org] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch organization" }, + { status: 500 } + ); + } +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export async function PUT(req: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.orgId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const membership = await prisma.orgMember.findFirst({ + where: { + userId: session.user.id, + organizationId: session.user.orgId, + }, + }); + + if (!membership || !["owner", "admin"].includes(membership.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { name, type, timezone } = await req.json(); + + const data: Record = {}; + if (name) { + data.name = name; + data.slug = slugify(name); + } + if (type) data.type = type; + if (timezone) data.timezone = timezone; + + await prisma.organization.update({ + where: { id: session.user.orgId }, + data, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[org] PUT error:", error); + return NextResponse.json( + { error: "Failed to update organization" }, + { status: 500 } + ); + } +}