133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
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 })
|
||
|
|
}
|
||
|
|
|