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:
co-authored by
Claude Opus 4.8
parent
917a06ee85
commit
5495b94924
+21
-11
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user