Files

133 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

import { NextResponse } from "next/server"
import { and, desc, eq, ne } from "drizzle-orm"
import { z } from "zod"
import { db } from "@/lib/db"
import { account_members, profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import { sendEmail, teamInviteHtml } from "@/lib/email/send"
import type { Plan } from "@/types"
const inviteSchema = z.object({
email: z.string().email("Enter a valid email address").max(254),
role: z.enum(["member", "viewer"]).default("member"),
})
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
/**
* GET — list the members the current OWNER has invited to their account.
* Team management is always scoped to the real session user acting as owner
* (never getEffectiveOwnerId), so a member can't manage the owner's team.
*/
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const members = await db
.select({
id: account_members.id,
email: account_members.email,
role: account_members.role,
status: account_members.status,
member_id: account_members.member_id,
accepted_at: account_members.accepted_at,
created_at: account_members.created_at,
})
.from(account_members)
.where(and(eq(account_members.owner_id, user.id), ne(account_members.status, "revoked")))
.orderBy(desc(account_members.created_at))
return NextResponse.json(members)
}
/**
* POST — invite a user (by email) to the current owner's account. Gated to
* plans with team access; only the account owner may invite.
*/
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Only an account OWNER can manage a team — a member of someone else's
// account must not be able to invite people into that account.
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) {
return NextResponse.json(
{ error: "Only the account owner can manage the team" },
{ status: 403 }
)
}
// Gate on the owner's plan.
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { plan: true, email: true },
})
const plan = (profile?.plan ?? "starter") as Plan
if (!PLAN_LIMITS[plan].hasTeamAccess) {
return NextResponse.json(
{ error: "Team access is available on the Landlord and Lifetime plans" },
{ status: 403 }
)
}
const body = await request.json().catch(() => null)
const parsed = inviteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
)
}
const email = parsed.data.email.trim().toLowerCase()
const { role } = parsed.data
// Can't invite yourself.
if (email === (profile?.email ?? user.email ?? "").toLowerCase()) {
return NextResponse.json({ error: "You can't invite yourself" }, { status: 400 })
}
// Prevent a duplicate pending/active invite for the same email.
const existing = await db.query.account_members.findFirst({
where: and(eq(account_members.owner_id, user.id), eq(account_members.email, email)),
})
if (existing && (existing.status === "pending" || existing.status === "active")) {
return NextResponse.json(
{
error:
existing.status === "active"
? "That person is already a member of your account"
: "An invite is already pending for that email",
},
{ status: 409 }
)
}
let inviteToken: string
try {
// Re-invite a previously revoked email by inserting a fresh pending row.
const [row] = await db
.insert(account_members)
.values({ owner_id: user.id, email, role, status: "pending" })
.returning({ invite_token: account_members.invite_token })
inviteToken = row.invite_token
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 })
}
const inviteUrl = `${APP_URL}/team/accept/${inviteToken}`
const inviterName = profile?.email ?? user.email ?? "A landlord"
await sendEmail({
to: email,
subject: "You've been invited to a Property Management Network account",
html: teamInviteHtml({ inviterName, inviteUrl, role }),
})
return NextResponse.json({ ok: true })
}