Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -4,14 +4,18 @@ import { db } from "@/lib/db"
|
||||
import { properties } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { propertySchema } from "@/lib/validations"
|
||||
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
|
||||
import { geocodeAddress } from "@/lib/geocoding"
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const ownerId = await getEffectiveOwnerId(user.id)
|
||||
|
||||
const { id } = await params
|
||||
const data = await db.query.properties.findFirst({
|
||||
where: and(eq(properties.id, id), eq(properties.user_id, user.id)),
|
||||
where: and(eq(properties.id, id), eq(properties.user_id, ownerId)),
|
||||
with: { units: {} },
|
||||
})
|
||||
|
||||
@@ -23,16 +27,36 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const ctx = await getAccountContext(user.id)
|
||||
const ownerId = ctx.ownerId
|
||||
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const parsed = propertySchema.partial().safeParse(body)
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
|
||||
// Re-geocode only when an address field actually changed. Merge with the
|
||||
// existing row so a partial update still geocodes the complete address. Keep
|
||||
// the old coordinates if geocoding fails (never overwrite good data with null).
|
||||
const addressKeys = ["address_line1", "address_line2", "city", "state", "postal_code", "country"] as const
|
||||
let coords: { latitude: number; longitude: number } | undefined
|
||||
if (addressKeys.some((k) => k in parsed.data)) {
|
||||
const existing = await db.query.properties.findFirst({
|
||||
where: and(eq(properties.id, id), eq(properties.user_id, ownerId)),
|
||||
columns: { address_line1: true, address_line2: true, city: true, state: true, postal_code: true, country: true },
|
||||
})
|
||||
if (existing) {
|
||||
const geo = await geocodeAddress({ ...existing, ...parsed.data })
|
||||
if (geo) coords = geo
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [data] = await db
|
||||
.update(properties)
|
||||
.set(parsed.data)
|
||||
.where(and(eq(properties.id, id), eq(properties.user_id, user.id)))
|
||||
.set({ ...parsed.data, ...(coords ?? {}) })
|
||||
.where(and(eq(properties.id, id), eq(properties.user_id, ownerId)))
|
||||
.returning()
|
||||
|
||||
return NextResponse.json(data)
|
||||
@@ -45,9 +69,13 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const ctx = await getAccountContext(user.id)
|
||||
const ownerId = ctx.ownerId
|
||||
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
try {
|
||||
await db.delete(properties).where(and(eq(properties.id, id), eq(properties.user_id, user.id)))
|
||||
await db.delete(properties).where(and(eq(properties.id, id), eq(properties.user_id, ownerId)))
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 500 })
|
||||
|
||||
+23
-13
@@ -1,16 +1,23 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { desc, eq, sql } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { properties, profiles } from "@/lib/db/schema"
|
||||
import { properties } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { propertySchema } from "@/lib/validations"
|
||||
import { getUserPlan } from "@/lib/plan-limits"
|
||||
import { checkLimit } from "@/lib/stripe/plans"
|
||||
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
|
||||
import { emitWebhookEvent } from "@/lib/webhooks/emit"
|
||||
import { geocodeAddress } from "@/lib/geocoding"
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const ownerId = await getEffectiveOwnerId(user.id)
|
||||
|
||||
const data = await db.query.properties.findMany({
|
||||
where: eq(properties.user_id, user.id),
|
||||
where: eq(properties.user_id, ownerId),
|
||||
with: { units: { columns: { id: true, status: true } } },
|
||||
orderBy: desc(properties.created_at),
|
||||
})
|
||||
@@ -22,6 +29,10 @@ export async function POST(request: Request) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const ctx = await getAccountContext(user.id)
|
||||
const ownerId = ctx.ownerId
|
||||
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = propertySchema.safeParse(body)
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
@@ -30,23 +41,22 @@ export async function POST(request: Request) {
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(properties)
|
||||
.where(eq(properties.user_id, user.id))
|
||||
.where(eq(properties.user_id, ownerId))
|
||||
|
||||
const profile = await db.query.profiles.findFirst({
|
||||
where: eq(profiles.id, user.id),
|
||||
columns: { plan: true },
|
||||
})
|
||||
const limits: Record<string, number> = { starter: 1, pro: 10, landlord: Infinity, lifetime: Infinity }
|
||||
const limit = limits[profile?.plan ?? "starter"] ?? 1
|
||||
|
||||
if (count >= limit) {
|
||||
const plan = await getUserPlan(ownerId)
|
||||
if (!checkLimit(plan, "maxProperties", count)) {
|
||||
return NextResponse.json({ error: "Plan limit reached. Upgrade to add more properties." }, { status: 403 })
|
||||
}
|
||||
|
||||
// Best-effort geocode so the property shows up on the map (never blocks save).
|
||||
const coords = await geocodeAddress(parsed.data)
|
||||
|
||||
const [data] = await db
|
||||
.insert(properties)
|
||||
.values({ ...parsed.data, user_id: user.id })
|
||||
.values({ ...parsed.data, user_id: ownerId, ...(coords ?? {}) })
|
||||
.returning()
|
||||
|
||||
await emitWebhookEvent({ ownerId, event: "property.created", data: { property: data } })
|
||||
|
||||
return NextResponse.json(data, { status: 201 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user