Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes

Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-03 04:45:24 -04:00
co-authored by Claude Opus 4.8
parent 917a06ee85
commit 5495b94924
86 changed files with 7647 additions and 1182 deletions
+21 -11
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -21,6 +22,9 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_ask")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -154,16 +158,22 @@ ${dataBlock("EXPIRING LEASES", JSON.stringify(expiringLeases, null, 2))}
Answer the landlord's question in a helpful, concise, and professional manner. Use bullet points where appropriate. Be specific with numbers from the data above. If the question is unrelated to property management, politely redirect.
`
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1024,
messages: [
{ role: "system", content: context },
{ role: "user", content: question },
],
})
const answer = completion.choices[0].message.content ?? ""
let answer: string
try {
answer = await aiComplete({
messages: [
{ role: "system", content: context },
{ role: "user", content: question },
],
maxTokens: 1024,
})
} catch (err) {
console.error("[ai/ask] AI request failed:", err)
return NextResponse.json(
{ error: "The AI service is temporarily unavailable. Please try again in a moment." },
{ status: 502 }
)
}
return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } })
}
+9 -7
View File
@@ -4,7 +4,8 @@ import { db } from "@/lib/db"
import { properties, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -12,6 +13,9 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_maintenance_summary")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -39,9 +43,7 @@ export async function POST(request: Request) {
columns: { name: true },
})
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1024,
const text = await aiComplete({
messages: [
{ role: "system", content: MAINTENANCE_SUMMARY_PROMPT },
{
@@ -49,13 +51,13 @@ export async function POST(request: Request) {
content: `${dataBlock("PROPERTY NAME", property?.name ?? "Unknown")}\n\n${dataBlock("MAINTENANCE REQUESTS", JSON.stringify(requests, null, 2))}`,
},
],
maxTokens: 1024,
json: true,
})
const text = completion.choices[0].message.content ?? ""
let summary
try {
summary = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim())
summary = JSON.parse(text)
} catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
}
+9 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -38,6 +39,9 @@ export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_predictions")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -174,16 +178,15 @@ Generate a JSON object with key "predictions" containing an array of 5-7 predict
Only return valid JSON, no other text.`
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 2000,
const content = await aiComplete({
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
maxTokens: 2000,
json: true,
})
let predictions: any[] = []
try {
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}")
const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
} catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
+9 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -37,6 +38,9 @@ export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_recommendations")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -167,13 +171,12 @@ Only return valid JSON, no other text.`
let recommendations: any[] = []
try {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1500,
const content = await aiComplete({
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
maxTokens: 1500,
json: true,
})
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}")
const parsed = JSON.parse(content || "{}")
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
} catch (err: any) {
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 })
+9 -7
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { getSessionUser } from "@/lib/session"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { RENT_RECEIPT_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -25,6 +26,9 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_rent_receipt")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -36,9 +40,7 @@ export async function POST(request: Request) {
// Pass only the whitelisted, validated fields to the model.
const { payment_id, ...receiptFields } = parsed.data
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1024,
const text = await aiComplete({
messages: [
{ role: "system", content: RENT_RECEIPT_PROMPT },
{
@@ -46,13 +48,13 @@ export async function POST(request: Request) {
content: dataBlock("PAYMENT DETAILS", JSON.stringify(receiptFields, null, 2)),
},
],
maxTokens: 1024,
json: true,
})
const text = completion.choices[0].message.content ?? ""
let receipt
try {
receipt = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim())
receipt = JSON.parse(text)
} catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
}
+1 -1
View File
@@ -44,7 +44,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
if (doc.storage_path) {
await deleteFile(doc.storage_path)
await deleteFile(doc.storage_path, ownerId)
}
return NextResponse.json({ success: true })
+28 -3
View File
@@ -3,7 +3,14 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { documents, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import {
saveFile,
isAllowedUploadExt,
StorageNotConfiguredError,
keyBelongsToOwner,
contentMatchesExtension,
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
@@ -57,6 +64,10 @@ export async function POST(request: Request) {
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 })
if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
if (!contentMatchesExtension(head, extOf(file.name))) {
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
}
const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
@@ -113,6 +124,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 })
}
// The file reference is client-supplied. Require it to be an /api/files URL
// inside the caller's OWN namespace, and derive storage_path from it — never
// trust a separate client storage_path (which could point at another tenant's
// object and later be deleted). Also blocks javascript:/external file_url values.
const FILES_PREFIX = "/api/files/"
const fileUrl = typeof body.file_url === "string" ? body.file_url : ""
if (!fileUrl.startsWith(FILES_PREFIX)) {
return NextResponse.json({ error: "file_url must reference an uploaded file" }, { status: 400 })
}
const storagePath = fileUrl.slice(FILES_PREFIX.length)
if (!keyBelongsToOwner(storagePath, ownerId)) {
return NextResponse.json({ error: "Invalid file reference" }, { status: 403 })
}
// Whitelist insertable columns — never trust client-supplied user_id/id/created_at.
const [data] = await db
.insert(documents)
@@ -122,8 +147,8 @@ export async function POST(request: Request) {
tenant_id: tenantId,
name: body.name as string,
category: (body.category as typeof documents.$inferInsert.category) ?? "other",
file_url: body.file_url as string,
storage_path: body.storage_path as string | undefined,
file_url: fileUrl,
storage_path: storagePath,
file_type: body.file_type as string | undefined,
file_size: body.file_size as number | undefined,
})
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getAdapter, saveEsignConnection, type ESignProvider } from "@/lib/esign"
import { verifyState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state"
// OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params
const adapter = getAdapter(provider)
const settings = new URL("/settings/integrations", request.url)
const cookieStore = await cookies()
const nonceCookie = cookieStore.get(ESIGN_NONCE_COOKIE)?.value
const done = (p: Record<string, string>) => {
for (const [k, v] of Object.entries(p)) settings.searchParams.set(k, v)
const res = NextResponse.redirect(settings)
res.cookies.set(ESIGN_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
return res
}
const url = new URL(request.url)
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const oauthError = url.searchParams.get("error")
if (oauthError || !adapter || adapter.kind !== "oauth") return done({ error: "connect_failed" })
const st = state ? verifyState(state) : null
// CSRF: state nonce must match the cookie, and the session must be the same owner.
if (!code || !st || st.provider !== provider || !nonceCookie || nonceCookie !== st.nonce) {
return done({ error: "invalid_state" })
}
const user = await getSessionUser()
if (!user) return done({ error: "invalid_state" })
const ctx = await getAccountContext(user.id)
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
try {
const tokens = await adapter.exchangeCode(code)
if (!tokens.accountId || !tokens.baseUri) throw new Error("No account returned from provider")
await saveEsignConnection(st.ownerId, provider as ESignProvider, tokens)
return done({ connected: provider })
} catch {
return done({ error: "connect_failed" })
}
}
+49
View File
@@ -0,0 +1,49 @@
import crypto from "crypto"
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getAdapter } from "@/lib/esign"
import { signState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state"
// Starts the OAuth connect flow for an e-signature provider (owner-only).
// API-key providers (Dropbox Sign) don't use this — they connect via a form.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params
const adapter = getAdapter(provider)
const settings = new URL("/settings/integrations", request.url)
if (!adapter) {
settings.searchParams.set("error", "unknown_provider")
return NextResponse.redirect(settings)
}
const user = await getSessionUser()
if (!user) return NextResponse.redirect(new URL("/login", request.url))
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) {
settings.searchParams.set("error", "owner_only")
return NextResponse.redirect(settings)
}
if (adapter.kind !== "oauth") {
settings.searchParams.set("error", "use_api_key")
return NextResponse.redirect(settings)
}
if (!adapter.available()) {
settings.searchParams.set("error", "not_configured")
return NextResponse.redirect(settings)
}
// Bind the round-trip to this browser: nonce in the signed state AND a cookie.
const nonce = crypto.randomUUID()
const state = signState({ ownerId: ctx.ownerId, provider, nonce })
const res = NextResponse.redirect(adapter.getAuthUrl(state))
res.cookies.set(ESIGN_NONCE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
})
return res
}
+5 -3
View File
@@ -1,15 +1,17 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { getAccountContext } from "@/lib/account"
import { runFollowUpsForUser } from "@/lib/follow-ups"
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
// Sends real outbound follow-ups — a mutating action, so viewers are blocked.
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const result = await runFollowUpsForUser(ownerId)
const result = await runFollowUpsForUser(ctx.ownerId)
// Preserve the original response shape ({ sent, results }). The detailed
// per-follow-up rows now live only in follow_up_log; the client re-fetches
@@ -1,37 +1,51 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getProvider, saveConnection, type Provider } from "@/lib/accounting"
import { verifyState } from "@/lib/accounting/state"
import { verifyState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
// OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider: pid } = await params
const prov = getProvider(pid)
const url = new URL(request.url)
const settings = new URL("/settings/integrations", request.url)
// Always clear the one-shot nonce cookie on the way out.
const cookieStore = await cookies()
const nonceCookie = cookieStore.get(OAUTH_NONCE_COOKIE)?.value
const done = (params: Record<string, string>) => {
for (const [k, v] of Object.entries(params)) settings.searchParams.set(k, v)
const res = NextResponse.redirect(settings)
res.cookies.set(OAUTH_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
return res
}
const url = new URL(request.url)
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const realmId = url.searchParams.get("realmId") // QuickBooks includes this
const oauthError = url.searchParams.get("error")
if (oauthError || !prov) {
settings.searchParams.set("error", "connect_failed")
return NextResponse.redirect(settings)
}
if (oauthError || !prov) return done({ error: "connect_failed" })
const st = state ? verifyState(state) : null
if (!code || !st || st.provider !== pid) {
settings.searchParams.set("error", "invalid_state")
return NextResponse.redirect(settings)
// CSRF: the state's nonce must match the cookie set at connect time, and the
// current session must be the same owner that initiated the connect.
if (!code || !st || st.provider !== pid || !nonceCookie || nonceCookie !== st.nonce) {
return done({ error: "invalid_state" })
}
const user = await getSessionUser()
if (!user) return done({ error: "invalid_state" })
const ctx = await getAccountContext(user.id)
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
try {
const tokens = await prov.exchangeCode(code, realmId)
if (!tokens.realmId) throw new Error("No organisation returned from provider")
await saveConnection(st.ownerId, pid as Provider, tokens)
settings.searchParams.set("connected", pid)
return done({ connected: pid })
} catch {
settings.searchParams.set("error", "connect_failed")
return done({ error: "connect_failed" })
}
return NextResponse.redirect(settings)
}
@@ -1,8 +1,9 @@
import crypto from "crypto"
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getProvider } from "@/lib/accounting"
import { signState } from "@/lib/accounting/state"
import { signState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
// Starts the OAuth connect flow for an accounting provider (owner-only).
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
@@ -28,6 +29,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov
return NextResponse.redirect(settings)
}
const state = signState({ ownerId: ctx.ownerId, provider: pid })
return NextResponse.redirect(prov.getAuthUrl(state))
// Bind the OAuth round-trip to this browser: a random nonce goes into the
// signed state AND an httpOnly cookie; the callback requires them to match.
const nonce = crypto.randomUUID()
const state = signState({ ownerId: ctx.ownerId, provider: pid, nonce })
const res = NextResponse.redirect(prov.getAuthUrl(state))
res.cookies.set(OAUTH_NONCE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
})
return res
}
-32
View File
@@ -1,32 +0,0 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { cancelSubscription } from "@/lib/paypal/checkout"
// Cancel the signed-in user's PayPal subscription. The account keeps access
// until the paid period ends; the BILLING.SUBSCRIPTION.CANCELLED webhook does
// the final downgrade to starter.
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { paypal_subscription_id: true },
})
if (!profile?.paypal_subscription_id) {
return NextResponse.json({ error: "No PayPal subscription to cancel" }, { status: 400 })
}
const ok = await cancelSubscription(profile.paypal_subscription_id)
if (!ok) return NextResponse.json({ error: "PayPal cancellation failed" }, { status: 502 })
await db
.update(profiles)
.set({ subscription_status: "canceled" })
.where(eq(profiles.id, user.id))
return NextResponse.json({ ok: true })
}
-73
View File
@@ -1,73 +0,0 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { paypalConfigured } from "@/lib/paypal/client"
import { getPaypalPlanId } from "@/lib/paypal/plans"
import { createSubscription, createOrder } from "@/lib/paypal/checkout"
import { PLAN_AMOUNTS } from "@/lib/stripe/plans"
const RECURRING = new Set(["pro", "landlord"])
// Start a PayPal checkout for a plan upgrade and return the approval URL.
// Recurring plans → Subscriptions API; lifetime → one-time Orders API.
export async function POST(request: Request) {
if (!paypalConfigured()) {
return NextResponse.json({ error: "PayPal is not configured" }, { status: 400 })
}
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { plan, interval } = (await request.json().catch(() => ({}))) as {
plan?: string
interval?: "month" | "year"
}
if (!plan || (plan !== "lifetime" && !RECURRING.has(plan))) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 })
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const cancelUrl = `${appUrl}/settings/billing?canceled=true`
try {
if (plan === "lifetime") {
const { approveUrl } = await createOrder({
amount: PLAN_AMOUNTS.lifetime,
userId: user.id,
plan: "lifetime",
returnUrl: `${appUrl}/api/paypal/return?type=order`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
}
const billingInterval = interval === "year" ? "year" : "month"
const planId = getPaypalPlanId(plan as "pro" | "landlord", billingInterval)
if (!planId) {
return NextResponse.json({ error: "That plan isn't available on PayPal yet." }, { status: 400 })
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { email: true },
})
const { approveUrl } = await createSubscription({
planId,
userId: user.id,
plan,
email: profile?.email ?? user.email,
returnUrl: `${appUrl}/api/paypal/return?type=subscription`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message || "PayPal checkout failed" },
{ status: 502 }
)
}
}
-52
View File
@@ -1,52 +0,0 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { captureOrder, getSubscription, decodeCustomId } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime } from "@/lib/paypal/fulfill"
// PayPal redirects the approver back here. We finalize synchronously (capture
// the order / confirm the subscription) so the plan is live the moment they
// land on the billing page — the webhook is a backstop, not the only path.
export async function GET(request: Request) {
const url = new URL(request.url)
const type = url.searchParams.get("type")
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const ok = NextResponse.redirect(`${appUrl}/settings/billing?success=true`)
const fail = NextResponse.redirect(`${appUrl}/settings/billing?error=paypal`)
const user = await getSessionUser()
if (!user) return NextResponse.redirect(`${appUrl}/login`)
try {
if (type === "order") {
const orderId = url.searchParams.get("token")
if (!orderId) return fail
const captured = await captureOrder(orderId)
if (!captured || captured.status !== "COMPLETED") return fail
const decoded = decodeCustomId(captured.custom_id)
if (!decoded || decoded.userId !== user.id) return fail
await fulfillLifetime(user.id)
return ok
}
// Subscription approval.
const subId = url.searchParams.get("subscription_id")
if (!subId) return fail
const sub = await getSubscription(subId)
if (!sub) return fail
const decoded = decodeCustomId(sub.custom_id)
// Only accept a subscription whose custom_id matches the signed-in user.
if (!decoded || decoded.userId !== user.id) return fail
const active = sub.status === "ACTIVE" || sub.status === "APPROVED"
await fulfillSubscription(
user.id,
decoded.plan,
sub.id,
sub.billing_info?.next_billing_time,
active ? "active" : sub.status.toLowerCase()
)
return ok
} catch {
return fail
}
}
-89
View File
@@ -1,89 +0,0 @@
import { NextResponse } from "next/server"
import { verifyPaypalWebhook } from "@/lib/paypal/webhook"
import { decodeCustomId, getSubscription } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime, markPaypalSubscriptionInactive } from "@/lib/paypal/fulfill"
// Inbound PayPal webhook. Signature is verified via PayPal's API using
// PAYPAL_WEBHOOK_ID; unverified events are rejected.
export async function POST(request: Request) {
const body = await request.text()
const valid = await verifyPaypalWebhook(request.headers, body)
if (!valid) return NextResponse.json({ error: "invalid signature" }, { status: 400 })
let event: { event_type?: string; resource?: Record<string, unknown> }
try {
event = JSON.parse(body)
} catch {
return NextResponse.json({ ok: true })
}
const type = event.event_type ?? ""
const resource = (event.resource ?? {}) as Record<string, any>
try {
switch (type) {
case "BILLING.SUBSCRIPTION.ACTIVATED":
case "BILLING.SUBSCRIPTION.UPDATED": {
const decoded = decodeCustomId(resource.custom_id)
if (decoded && resource.id) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
resource.id,
resource.billing_info?.next_billing_time,
"active"
)
}
break
}
case "PAYMENT.SALE.COMPLETED": {
// A recurring payment cleared — refresh status + next billing date.
const subId = resource.billing_agreement_id as string | undefined
if (subId) {
const sub = await getSubscription(subId)
const decoded = decodeCustomId(sub?.custom_id)
if (sub && decoded) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
subId,
sub.billing_info?.next_billing_time,
"active"
)
}
}
break
}
case "BILLING.SUBSCRIPTION.CANCELLED":
case "BILLING.SUBSCRIPTION.EXPIRED": {
if (resource.id) {
await markPaypalSubscriptionInactive(
resource.id,
type.endsWith("CANCELLED") ? "canceled" : "expired",
true
)
}
break
}
case "BILLING.SUBSCRIPTION.SUSPENDED": {
if (resource.id) await markPaypalSubscriptionInactive(resource.id, "suspended", false)
break
}
case "PAYMENT.CAPTURE.COMPLETED": {
// Lifetime order capture (backup to the return handler).
const decoded = decodeCustomId(resource.custom_id)
if (decoded && decoded.plan === "lifetime") await fulfillLifetime(decoded.userId)
break
}
}
} catch {
// Never loop forever on a handler bug — PayPal retries non-2xx.
}
return NextResponse.json({ received: true })
}
+9
View File
@@ -8,8 +8,17 @@ export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Exclude bearer/secret + billing-id columns from the client payload. The
// calendar feed token and Stripe/PayPal ids are used server-side only.
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: {
calendar_token: false,
stripe_customer_id: false,
stripe_subscription_id: false,
paypal_subscription_id: false,
billing_provider: false,
},
})
return NextResponse.json({ profile: profile ?? null })
+13 -1
View File
@@ -1,7 +1,13 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import {
saveFile,
isAllowedUploadExt,
StorageNotConfiguredError,
contentMatchesExtension,
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
@@ -33,6 +39,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
}
// Reject files whose real content doesn't match the claimed extension.
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
if (!contentMatchesExtension(head, extOf(file.name))) {
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
}
// Enforce per-plan storage quota (accounts for everything already stored in
// the owner's portfolio namespace).
const storageError = await checkStorageLimit(ownerId, file.size)