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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-03 04:45:24 -04:00
co-authored by Claude Opus 4.8
parent 917a06ee85
commit 5495b94924
86 changed files with 7647 additions and 1182 deletions
+1 -36
View File
@@ -9,7 +9,6 @@ export function CheckoutButton({
highlight,
interval = "month",
annualAvailable = false,
paypalEnabled = false,
}: {
plan: string
label: string
@@ -18,11 +17,8 @@ export function CheckoutButton({
// When true, show a monthly/annual choice. Only pass this for subscription
// plans and only when annual billing is actually configured server-side.
annualAvailable?: boolean
// When true, also offer "Pay with PayPal" using the same interval choice.
paypalEnabled?: boolean
}) {
const [loading, setLoading] = useState(false)
const [paypalLoading, setPaypalLoading] = useState(false)
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
const effectiveInterval = annualAvailable ? chosenInterval : interval
@@ -39,21 +35,6 @@ export function CheckoutButton({
else setLoading(false)
}
async function handlePaypal() {
setPaypalLoading(true)
const res = await fetch("/api/paypal/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan, interval: effectiveInterval }),
})
const data = await res.json()
if (data.url) window.location.href = data.url
else {
setPaypalLoading(false)
if (data.error) alert(data.error)
}
}
return (
<div className="space-y-2">
{annualAvailable && (
@@ -82,7 +63,7 @@ export function CheckoutButton({
)}
<button
onClick={handleClick}
disabled={loading || paypalLoading}
disabled={loading}
className={cn(
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
highlight
@@ -92,22 +73,6 @@ export function CheckoutButton({
>
{loading ? "Loading..." : label}
</button>
{paypalEnabled && (
<button
onClick={handlePaypal}
disabled={loading || paypalLoading}
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-[#ffc439] py-2 text-xs font-bold text-[#003087] transition hover:bg-[#f0b90b] disabled:opacity-50"
>
{paypalLoading ? (
"Loading..."
) : (
<>
Pay with <span className="font-extrabold italic">Pay<span className="text-[#009cde]">Pal</span></span>
</>
)}
</button>
)}
</div>
)
}
+24 -8
View File
@@ -1,8 +1,9 @@
"use client"
import { useTransition } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle, Download } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
@@ -12,11 +13,12 @@ type Req = {
status: string
signer_email: string
document_name: string | null
signed_document_url: string | null
sent_at: string | null
completed_at: string | null
last_error: string | null
}
type Prov = { id: string; label: string; configured: boolean }
type Prov = { id: string; label: string }
const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = {
sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock },
@@ -29,18 +31,17 @@ const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_s
export function EsignLease({
leaseId,
providers,
connected,
requests,
canSend,
disabledReason,
}: {
leaseId: string
providers: Prov[]
connected: Prov[]
requests: Req[]
canSend: boolean
disabledReason: string
}) {
const configured = providers.filter((p) => p.configured)
const [pending, start] = useTransition()
function send(provider: string) {
@@ -74,6 +75,16 @@ export function EsignLease({
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
</p>
{r.signed_document_url && (
<a
href={r.signed_document_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-[11px] text-indigo-400 transition hover:text-indigo-300"
>
<Download className="h-3 w-3" /> Signed document
</a>
)}
</div>
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}>
<s.Icon className="h-3 w-3" /> {s.label}
@@ -84,11 +95,16 @@ export function EsignLease({
</ul>
)}
{configured.length === 0 ? (
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p>
{connected.length === 0 ? (
<p className="text-xs text-white/30">
<Link href="/settings/integrations" className="text-indigo-400 hover:text-indigo-300">
Connect DocuSign or Dropbox Sign
</Link>{" "}
in Settings Integrations to send leases for signature.
</p>
) : canSend ? (
<div className="flex flex-wrap gap-2">
{configured.map((p) => (
{connected.map((p) => (
<button
key={p.id}
onClick={() => send(p.id)}
+99
View File
@@ -0,0 +1,99 @@
"use client"
import { useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { FileText, Upload, ExternalLink, Loader2 } from "lucide-react"
import { setLeaseDocument } from "@/app/actions/esign"
export function LeaseDocument({
leaseId,
documentUrl,
canWrite,
}: {
leaseId: string
documentUrl: string | null
canWrite: boolean
}) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (!/\.(pdf|docx?)$/i.test(file.name)) {
toast.error("Upload a PDF or Word document")
if (inputRef.current) inputRef.current.value = ""
return
}
setUploading(true)
try {
const fd = new FormData()
fd.append("file", file)
fd.append("scope", "documents")
const res = await fetch("/api/upload", { method: "POST", body: fd })
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j?.error || "Upload failed")
}
const { url } = (await res.json()) as { url: string }
await setLeaseDocument(leaseId, url)
toast.success(documentUrl ? "Lease document replaced" : "Lease document attached")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed")
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ""
}
}
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium text-white">
<FileText className="h-4 w-4 shrink-0 text-indigo-400" />
{documentUrl ? "Lease document" : "No lease document yet"}
</div>
{documentUrl && (
<a
href={documentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-indigo-400 transition hover:text-indigo-300"
>
View <ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
{!documentUrl && (
<p className="mt-1.5 text-xs text-white/40">Upload the lease PDF to enable sending it for e-signature.</p>
)}
{canWrite && (
<div className="mt-3">
<input
ref={inputRef}
type="file"
accept=".pdf,.doc,.docx"
onChange={onPick}
disabled={uploading}
className="hidden"
id={`lease-doc-${leaseId}`}
/>
<label
htmlFor={`lease-doc-${leaseId}`}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/[0.06] hover:text-white ${
uploading ? "pointer-events-none opacity-50" : ""
}`}
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{documentUrl ? "Replace document" : "Upload lease document"}
</label>
</div>
)}
</div>
)
}
-32
View File
@@ -1,32 +0,0 @@
"use client"
import { useState } from "react"
export function PaypalCancelButton() {
const [loading, setLoading] = useState(false)
async function handleClick() {
if (!confirm("Cancel your PayPal subscription? You'll keep access until the end of the current billing period.")) {
return
}
setLoading(true)
const res = await fetch("/api/paypal/cancel", { method: "POST" })
const data = await res.json().catch(() => ({}))
if (res.ok) {
window.location.href = "/settings/billing?canceled=true"
} else {
setLoading(false)
alert(data.error || "Could not cancel the subscription.")
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-50"
>
{loading ? "Canceling..." : "Cancel Subscription"}
</button>
)
}