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
@@ -0,0 +1,66 @@
|
||||
// Free, keyless forward geocoding via OpenStreetMap Nominatim.
|
||||
//
|
||||
// Usage policy — https://operations.osmfoundation.org/policies/nominatim/ :
|
||||
// • max 1 request/second, no heavy bulk use
|
||||
// • an identifying User-Agent is REQUIRED (set GEOCODER_USER_AGENT to a
|
||||
// contact URL/email in production)
|
||||
// • results must be cached — we persist latitude/longitude on the property,
|
||||
// so each address is geocoded once on save, never on map render.
|
||||
//
|
||||
// Best-effort by design: every failure path returns null and the caller simply
|
||||
// proceeds without coordinates (the property is omitted from the map).
|
||||
|
||||
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
|
||||
const USER_AGENT =
|
||||
process.env.GEOCODER_USER_AGENT ||
|
||||
`PropertyManagementNetwork/1.0 (${process.env.NEXT_PUBLIC_APP_URL || "https://propertymanagement.network"})`
|
||||
|
||||
export type Coordinates = { latitude: number; longitude: number }
|
||||
|
||||
export type AddressParts = {
|
||||
address_line1?: string | null
|
||||
address_line2?: string | null
|
||||
city?: string | null
|
||||
state?: string | null
|
||||
postal_code?: string | null
|
||||
country?: string | null
|
||||
}
|
||||
|
||||
/** True when there's enough of an address to bother geocoding. */
|
||||
export function hasGeocodableAddress(a: AddressParts): boolean {
|
||||
return Boolean(a.address_line1 || a.city || a.postal_code)
|
||||
}
|
||||
|
||||
export async function geocodeAddress(a: AddressParts): Promise<Coordinates | null> {
|
||||
if (!hasGeocodableAddress(a)) return null
|
||||
|
||||
const street = [a.address_line1, a.address_line2].filter(Boolean).join(" ").trim()
|
||||
const params = new URLSearchParams({ format: "jsonv2", limit: "1", addressdetails: "0" })
|
||||
if (street) params.set("street", street)
|
||||
if (a.city) params.set("city", a.city)
|
||||
if (a.state) params.set("state", a.state)
|
||||
if (a.postal_code) params.set("postalcode", a.postal_code)
|
||||
params.set("country", a.country || "US")
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 8000)
|
||||
try {
|
||||
const res = await fetch(`${NOMINATIM_URL}?${params.toString()}`, {
|
||||
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const json = (await res.json()) as Array<{ lat: string; lon: string }>
|
||||
const first = Array.isArray(json) ? json[0] : undefined
|
||||
if (!first) return null
|
||||
const latitude = Number(first.lat)
|
||||
const longitude = Number(first.lon)
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null
|
||||
return { latitude, longitude }
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user