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
@@ -1,6 +1,8 @@
|
||||
import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries"
|
||||
import { getMaintenanceMode } from "@/lib/settings"
|
||||
import { aiProviderStatus } from "@/lib/ai/provider"
|
||||
import { MaintenanceToggle } from "@/components/admin/maintenance-toggle"
|
||||
import { AiProviderToggle } from "@/components/admin/ai-provider-toggle"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { Settings, Database, Table2 } from "lucide-react"
|
||||
|
||||
@@ -13,10 +15,11 @@ function humanize(name: string): string {
|
||||
}
|
||||
|
||||
export default async function AdminSystemPage() {
|
||||
const [{ counts, cronLastRun }, env, maintenance] = await Promise.all([
|
||||
const [{ counts, cronLastRun }, env, maintenance, aiProvider] = await Promise.all([
|
||||
getSystemCounts(),
|
||||
Promise.resolve(getEnvHealth()),
|
||||
getMaintenanceMode(),
|
||||
aiProviderStatus(),
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -37,6 +40,18 @@ export default async function AdminSystemPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI provider selection */}
|
||||
<div className="mb-5">
|
||||
<AiProviderToggle
|
||||
selected={aiProvider.selected}
|
||||
effective={aiProvider.effective}
|
||||
openaiConfigured={aiProvider.openaiConfigured}
|
||||
anthropicConfigured={aiProvider.anthropicConfigured}
|
||||
openaiModel={aiProvider.openaiModel}
|
||||
anthropicModel={aiProvider.anthropicModel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
|
||||
{/* ── Environment configuration ───────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { Logo } from "@/components/shared/logo"
|
||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
|
||||
import { resetPassword } from "@/app/actions/auth"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reset password",
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { Logo } from "@/components/shared/logo"
|
||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
|
||||
import { signIn, signInWithGoogle } from "@/app/actions/auth"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in",
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -4,13 +4,16 @@ import { db } from "@/lib/db"
|
||||
import { leases as leasesTable } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { listAdapters, listRequestsForLease } from "@/lib/esign"
|
||||
import { listEsignConnections, listRequestsForLease } from "@/lib/esign"
|
||||
import Link from "next/link"
|
||||
import { FileText, ExternalLink } from "lucide-react"
|
||||
import { FileText } from "lucide-react"
|
||||
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LeaseActions } from "@/components/forms/lease-actions"
|
||||
import { EsignLease } from "@/components/forms/esign-lease"
|
||||
import { LeaseDocument } from "@/components/forms/lease-document"
|
||||
|
||||
const ESIGN_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
|
||||
|
||||
export const metadata = { title: "Lease" }
|
||||
|
||||
@@ -56,8 +59,12 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
|
||||
if (!lease) notFound()
|
||||
|
||||
const esignRequests = await listRequestsForLease(ownerId, leaseId)
|
||||
const esignProviders = listAdapters()
|
||||
const canSendEsign = ctx.canWrite && !!lease.document_url && !!lease.tenant?.email
|
||||
const esignConnections = await listEsignConnections(ownerId)
|
||||
const connectedProviders = esignConnections
|
||||
.filter((c) => c.status !== "revoked")
|
||||
.map((c) => ({ id: c.provider, label: ESIGN_LABEL[c.provider] ?? c.provider }))
|
||||
const canSendEsign =
|
||||
ctx.canWrite && !!lease.document_url && !!lease.tenant?.email && connectedProviders.length > 0
|
||||
const esignDisabledReason = !ctx.canWrite
|
||||
? "You have read-only access."
|
||||
: !lease.document_url
|
||||
@@ -196,24 +203,11 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lease.document_url && (
|
||||
<a
|
||||
href={lease.document_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-xl border border-white/[0.06] bg-[#16161f] p-5 transition hover:border-white/15"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
Lease document
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-white/40" />
|
||||
</a>
|
||||
)}
|
||||
<LeaseDocument leaseId={leaseId} documentUrl={lease.document_url} canWrite={ctx.canWrite} />
|
||||
|
||||
<EsignLease
|
||||
leaseId={leaseId}
|
||||
providers={esignProviders}
|
||||
connected={connectedProviders}
|
||||
requests={esignRequests}
|
||||
canSend={canSendEsign}
|
||||
disabledReason={esignDisabledReason}
|
||||
|
||||
@@ -5,9 +5,7 @@ import { profiles, properties, tenants } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { CheckoutButton } from "@/components/forms/checkout-button"
|
||||
import { PortalButton } from "@/components/forms/portal-button"
|
||||
import { PaypalCancelButton } from "@/components/forms/paypal-cancel-button"
|
||||
import { getPlanLabel, PLAN_LIMITS, annualEnabled } from "@/lib/stripe/plans"
|
||||
import { paypalConfigured } from "@/lib/paypal/client"
|
||||
import { Check } from "lucide-react"
|
||||
import type { Plan } from "@/types"
|
||||
|
||||
@@ -59,7 +57,7 @@ const PLANS = [
|
||||
export default async function BillingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ success?: string; canceled?: string; error?: string }>
|
||||
searchParams: Promise<{ success?: string; canceled?: string }>
|
||||
}) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
@@ -72,16 +70,12 @@ export default async function BillingPage({
|
||||
plan_expires_at: true,
|
||||
stripe_customer_id: true,
|
||||
stripe_subscription_id: true,
|
||||
paypal_subscription_id: true,
|
||||
billing_provider: true,
|
||||
},
|
||||
})
|
||||
|
||||
const params = await searchParams
|
||||
const currentPlan = (profile?.plan ?? "starter") as Plan
|
||||
const hasStripeAccount = !!profile?.stripe_customer_id
|
||||
const isPaypal = profile?.billing_provider === "paypal" || !!profile?.paypal_subscription_id
|
||||
const paypalEnabled = paypalConfigured()
|
||||
const limits = PLAN_LIMITS[currentPlan]
|
||||
const canBillAnnually = annualEnabled()
|
||||
|
||||
@@ -113,12 +107,6 @@ export default async function BillingPage({
|
||||
Checkout canceled — no charge was made.
|
||||
</div>
|
||||
)}
|
||||
{params.error === "paypal" && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-5 py-4 text-sm text-red-400">
|
||||
We couldn't complete your PayPal payment. No charge was made — please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current plan */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -129,12 +117,9 @@ export default async function BillingPage({
|
||||
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
|
||||
)}
|
||||
</div>
|
||||
{currentPlan !== "starter" && currentPlan !== "lifetime" &&
|
||||
(isPaypal ? (
|
||||
<PaypalCancelButton />
|
||||
) : hasStripeAccount ? (
|
||||
<PortalButton />
|
||||
) : null)}
|
||||
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
|
||||
<PortalButton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,7 +182,6 @@ export default async function BillingPage({
|
||||
label={plan.cta}
|
||||
highlight={plan.highlight}
|
||||
annualAvailable={canBillAnnually && plan.key !== "lifetime"}
|
||||
paypalEnabled={paypalEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { redirect } from "next/navigation"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { listProviders, listConnections } from "@/lib/accounting"
|
||||
import { listEsignAdapters, listEsignConnections } from "@/lib/esign"
|
||||
import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations"
|
||||
import { EsignIntegrations } from "@/components/dashboard/esign-integrations"
|
||||
|
||||
export const metadata = { title: "Integrations" }
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -20,20 +22,45 @@ export default async function IntegrationsPage({
|
||||
const providers = listProviders()
|
||||
const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : []
|
||||
|
||||
const esignAdapters = listEsignAdapters()
|
||||
const esignConnections = ctx.isOwner ? await listEsignConnections(ctx.ownerId) : []
|
||||
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
||||
// DocuSign vs Dropbox Sign flash messages are keyed by provider id, so a single
|
||||
// connected/error param drives whichever card the user just acted on.
|
||||
const esignFlash = { connected: sp.connected, error: sp.error }
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Integrations</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
Connect your accounting software to automatically push rent income and expenses into your books.
|
||||
</p>
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">E-signature</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
Connect your own DocuSign or Dropbox Sign account to send leases for signature.
|
||||
</p>
|
||||
</div>
|
||||
<EsignIntegrations
|
||||
adapters={esignAdapters}
|
||||
connections={esignConnections}
|
||||
isOwner={ctx.isOwner}
|
||||
flash={esignFlash}
|
||||
webhookUrl={`${appUrl}/api/esign/dropbox_sign/webhook`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Accounting</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
Connect your accounting software to automatically push rent income and expenses into your books.
|
||||
</p>
|
||||
</div>
|
||||
<AccountingIntegrations
|
||||
providers={providers}
|
||||
connections={connections}
|
||||
isOwner={ctx.isOwner}
|
||||
flash={{ connected: sp.connected, error: sp.error }}
|
||||
/>
|
||||
</div>
|
||||
<AccountingIntegrations
|
||||
providers={providers}
|
||||
connections={connections}
|
||||
isOwner={ctx.isOwner}
|
||||
flash={{ connected: sp.connected, error: sp.error }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { z } from "zod"
|
||||
import { getAdminSession } from "@/lib/session"
|
||||
import { logAdminAction } from "@/lib/admin/audit"
|
||||
import { setMaintenanceMode } from "@/lib/settings"
|
||||
import { setAiProvider, type AiProvider } from "@/lib/ai/provider"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles, user as userTable } from "@/lib/db/schema"
|
||||
@@ -158,3 +159,22 @@ export async function setSiteMaintenance(enabled: boolean, message?: string) {
|
||||
revalidatePath("/", "layout")
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── AI provider ───────────────────────────────────────────────────────────────
|
||||
// Chooses which LLM provider powers all AI features (OpenAI or Anthropic/Claude),
|
||||
// persisted in app_settings. Applies immediately to every AI route.
|
||||
export async function setAiProviderAction(provider: string) {
|
||||
const a = await guard()
|
||||
if (provider !== "openai" && provider !== "anthropic") throw new Error("Invalid AI provider")
|
||||
|
||||
await setAiProvider(provider as AiProvider)
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "ai_provider",
|
||||
metadata: { provider },
|
||||
})
|
||||
|
||||
revalidatePath("/admin/system")
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
+6
-1
@@ -36,7 +36,12 @@ export async function signUp(formData: FormData) {
|
||||
headers: h,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof APIError ? e.message : "Sign up failed"
|
||||
const raw = e instanceof APIError ? e.message : "Sign up failed"
|
||||
// Don't reveal that an email is already registered (user enumeration) — the
|
||||
// "already exists" path must not be distinguishable from other failures.
|
||||
const msg = /exist|registered|already|taken/i.test(raw)
|
||||
? "We couldn't complete your sign-up. Please try a different email or sign in."
|
||||
: raw
|
||||
redirect(`/signup?error=${encodeURIComponent(msg)}`)
|
||||
}
|
||||
|
||||
|
||||
+63
-1
@@ -1,9 +1,27 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { leases } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { sendLeaseForSignature, getAdapter, type ESignProvider } from "@/lib/esign"
|
||||
import { keyBelongsToOwner } from "@/lib/storage"
|
||||
import {
|
||||
sendLeaseForSignature,
|
||||
getAdapter,
|
||||
saveEsignConnection,
|
||||
disconnectEsign,
|
||||
type ESignProvider,
|
||||
} from "@/lib/esign"
|
||||
|
||||
async function ownerGuard() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
const ctx = await getAccountContext(user.id)
|
||||
if (!ctx.isOwner) throw new Error("Only the account owner can manage integrations")
|
||||
return ctx
|
||||
}
|
||||
|
||||
export async function sendLeaseForSignatureAction(leaseId: string, provider: string) {
|
||||
const user = await getSessionUser()
|
||||
@@ -15,3 +33,47 @@ export async function sendLeaseForSignatureAction(leaseId: string, provider: str
|
||||
revalidatePath(`/leases/${leaseId}`)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** Connect Dropbox Sign by validating and storing the landlord's API key. */
|
||||
export async function connectDropboxSign(apiKey: string) {
|
||||
const ctx = await ownerGuard()
|
||||
const adapter = getAdapter("dropbox_sign")
|
||||
if (!adapter) throw new Error("Unknown provider")
|
||||
const tokens = await adapter.connectApiKey(typeof apiKey === "string" ? apiKey : "")
|
||||
await saveEsignConnection(ctx.ownerId, "dropbox_sign", tokens)
|
||||
revalidatePath("/settings/integrations")
|
||||
return { ok: true, accountName: tokens.accountName }
|
||||
}
|
||||
|
||||
export async function disconnectEsignAction(provider: string) {
|
||||
const ctx = await ownerGuard()
|
||||
if (!getAdapter(provider)) throw new Error("Unknown provider")
|
||||
await disconnectEsign(ctx.ownerId, provider as ESignProvider)
|
||||
revalidatePath("/settings/integrations")
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an already-uploaded document (via /api/upload) to a lease. Validates
|
||||
* the file belongs to the caller's namespace to prevent cross-tenant refs.
|
||||
*/
|
||||
export async function setLeaseDocument(leaseId: string, fileUrl: string) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
const ctx = await getAccountContext(user.id)
|
||||
if (!ctx.canWrite) throw new Error("You don't have permission to do that")
|
||||
|
||||
const prefix = "/api/files/"
|
||||
if (typeof fileUrl !== "string" || !fileUrl.startsWith(prefix)) throw new Error("Invalid document reference")
|
||||
if (!keyBelongsToOwner(fileUrl.slice(prefix.length), ctx.ownerId)) throw new Error("Invalid document reference")
|
||||
|
||||
const [row] = await db
|
||||
.update(leases)
|
||||
.set({ document_url: fileUrl })
|
||||
.where(and(eq(leases.id, leaseId), eq(leases.user_id, ctx.ownerId)))
|
||||
.returning({ id: leases.id })
|
||||
if (!row) throw new Error("Lease not found")
|
||||
|
||||
revalidatePath(`/leases/${leaseId}`)
|
||||
return { ok: true, url: fileUrl }
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function GlobalError({
|
||||
@@ -10,6 +11,7 @@ export default function GlobalError({
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error)
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
|
||||
+14
-2
@@ -12,11 +12,23 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
theme_color: "#09090b",
|
||||
icons: [
|
||||
{
|
||||
src: "/logo-mark.png",
|
||||
src: "/icon-192.png",
|
||||
type: "image/png",
|
||||
sizes: "100x100",
|
||||
sizes: "192x192",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
type: "image/png",
|
||||
sizes: "512x512",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icon-maskable-512.png",
|
||||
type: "image/png",
|
||||
sizes: "512x512",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
// Private/app areas. Mirrors PROTECTED_PATHS in proxy.ts, plus the API
|
||||
// surface and the token-gated tenant portal (both private but enforced
|
||||
// outside the cookie proxy). `/tenant-portal/` keeps the trailing slash so
|
||||
// it doesn't also block the indexable `/tenant-portal-info` marketing page.
|
||||
disallow: [
|
||||
"/dashboard",
|
||||
"/admin",
|
||||
"/api/",
|
||||
"/settings",
|
||||
"/onboarding",
|
||||
"/team",
|
||||
"/tenant-portal/",
|
||||
"/calendar",
|
||||
"/inspections",
|
||||
"/vendors",
|
||||
"/reports",
|
||||
"/activity",
|
||||
"/ai",
|
||||
"/ai-dashboard",
|
||||
"/predictions",
|
||||
"/recommendations",
|
||||
"/impact",
|
||||
"/follow-ups",
|
||||
"/properties",
|
||||
"/tenants",
|
||||
"/rent",
|
||||
"/maintenance",
|
||||
"/leases",
|
||||
"/expenses",
|
||||
],
|
||||
},
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
host: base,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export async function GET() {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
|
||||
const body = `User-agent: *
|
||||
Allow: /
|
||||
Disallow: /dashboard
|
||||
Disallow: /properties
|
||||
Disallow: /tenants
|
||||
Disallow: /rent
|
||||
Disallow: /maintenance
|
||||
Disallow: /leases
|
||||
Disallow: /expenses
|
||||
Disallow: /settings
|
||||
Disallow: /tenant-portal/
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: ${appUrl}/sitemap.xml`
|
||||
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
})
|
||||
}
|
||||
+33
-12
@@ -1,17 +1,38 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { LEGAL_PAGES } from "@/lib/legal"
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
|
||||
|
||||
type ChangeFrequency = MetadataRoute.Sitemap[number]["changeFrequency"]
|
||||
|
||||
// Single source of truth for the public, indexable URL surface. Every entry
|
||||
// must resolve to a real 200 page that is NOT noindex'd. Private/app routes are
|
||||
// blocked in app/robots.ts, and the /login and /forgot-password auth pages are
|
||||
// noindex, so all three are intentionally omitted here. /signup is kept as a
|
||||
// conversion landing page.
|
||||
const PAGES: { path: string; changeFrequency: ChangeFrequency; priority: number }[] = [
|
||||
{ path: "/", changeFrequency: "weekly", priority: 1 },
|
||||
{ path: "/tenant-portal-info", changeFrequency: "monthly", priority: 0.8 },
|
||||
{ path: "/api-docs", changeFrequency: "monthly", priority: 0.8 },
|
||||
{ path: "/signup", changeFrequency: "monthly", priority: 0.7 },
|
||||
// Legal pages are derived from the shared LEGAL_PAGES constant (the same list
|
||||
// the footer renders) so the sitemap can never drift from the real routes.
|
||||
...LEGAL_PAGES.map((page) => ({
|
||||
path: page.href,
|
||||
changeFrequency: "yearly" as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
]
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
|
||||
const lastModified = new Date("2026-07-01")
|
||||
// Build-time timestamp, refreshed on every deploy. We don't track per-page
|
||||
// modification dates, so a single honest "last built" date is used throughout.
|
||||
const lastModified = new Date()
|
||||
|
||||
return [
|
||||
{ url: base, lastModified, changeFrequency: "weekly", priority: 1 },
|
||||
{ url: `${base}/tenant-portal-info`, lastModified, changeFrequency: "monthly", priority: 0.8 },
|
||||
{ url: `${base}/api-docs`, lastModified, changeFrequency: "monthly", priority: 0.6 },
|
||||
{ url: `${base}/signup`, lastModified, changeFrequency: "monthly", priority: 0.7 },
|
||||
{ url: `${base}/privacy`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${base}/terms`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${base}/cookie-policy`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${base}/gdpr`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
]
|
||||
return PAGES.map(({ path, changeFrequency, priority }) => ({
|
||||
url: path === "/" ? base : `${base}${path}`,
|
||||
lastModified,
|
||||
changeFrequency,
|
||||
priority,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user