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
+91 -47
View File
@@ -1,30 +1,28 @@
# ─────────────────────────────────────────────────────────────────────────────
# DigitalOcean App Platform spec — Property Management Network
#
# Deploy: doctl apps create --spec .do/app.yaml
# Deploy: doctl apps create --spec .do/app.yaml (or the DO MCP apps-create)
# Update: doctl apps update <APP_ID> --spec .do/app.yaml
#
# SOURCE: image-based from DigitalOcean Container Registry (DOCR). The app's git
# lives on self-hosted Gitea, which App Platform cannot pull, so we build the
# Docker image ourselves and push it to DOCR. See DIGITALOCEAN.md for the full
# build/push/deploy walkthrough.
# SOURCE: App Platform builds the Dockerfile directly from GitHub
# (github.com/silkoserfo/property-management-network). Pushes to `main`
# auto-redeploy (deploy_on_push). No DOCR image build/push needed.
#
# SECRETS: values marked `type: SECRET` are placeholders — set the real values in
# the App Platform dashboard (App → Settings → Environment Variables) or via
# `doctl`. Never commit real secrets to this file.
# the App Platform dashboard (App → Settings → Environment Variables) or via the
# create spec. Never commit real secrets to this file.
# ─────────────────────────────────────────────────────────────────────────────
name: property-management-network
region: nyc
services:
- name: web
# Pre-built image pushed to DOCR (repository must exist in your registry).
image:
registry_type: DOCR
repository: property-management-network
tag: latest
deploy_on_push:
enabled: true
# Built by App Platform from GitHub using the repo Dockerfile.
github:
repo: silkoserfo/property-management-network
branch: main
deploy_on_push: true
dockerfile_path: Dockerfile
instance_count: 1
instance_size_slug: apps-s-1vcpu-1gb
http_port: 3000
@@ -37,32 +35,35 @@ services:
failure_threshold: 3
envs:
# ── App URLs ──────────────────────────────────────────────────────────
# ${APP_URL} resolves to the app's public URL at runtime. NOTE: the client
# bundle bakes NEXT_PUBLIC_APP_URL at *image build* time (see Dockerfile /
# DIGITALOCEAN.md), so build the image with the same URL you serve on.
# NEXT_PUBLIC_* are inlined into the client bundle at BUILD time, so they
# must be RUN_AND_BUILD_TIME with the literal domain we serve on.
- key: NEXT_PUBLIC_APP_URL
scope: RUN_TIME
value: ${APP_URL}
scope: RUN_AND_BUILD_TIME
value: https://propertymanagement.network
- key: BETTER_AUTH_URL
scope: RUN_TIME
value: ${APP_URL}
value: https://propertymanagement.network
- key: NEXT_PUBLIC_APP_NAME
scope: RUN_TIME
scope: RUN_AND_BUILD_TIME
value: Property Management Network
# ── Database (managed Postgres — use the PRIVATE host; see DIGITALOCEAN.md) ──
# ── Admin & auth policy ───────────────────────────────────────────────
- key: ADMIN_EMAILS
scope: RUN_TIME
value: leon@phluit.com
- key: REQUIRE_EMAIL_VERIFICATION
scope: RUN_TIME
value: "true"
# ── Database (managed Postgres — PRIVATE host, direct port 25060) ──
- key: DATABASE_URL
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# Verified TLS (encrypted + certificate-checked). DO Managed Postgres uses
# a CA that isn't in the system trust store, so paste the cluster's CA cert
# into DATABASE_CA: DO control panel → Database → Connection Details →
# "Download CA certificate", then paste its PEM contents as the DATABASE_CA
# secret in the App Platform dashboard. Without a valid CA the app will
# refuse to connect (fail loud) rather than run unverified.
# Emergency fallback ONLY (not for production): DATABASE_SSL=no-verify is
# encrypted but does NOT verify the server certificate.
# Verified TLS: DO's Managed Postgres CA isn't in the system trust store, so
# paste the cluster CA PEM (repo root ca-certificate.crt) into DATABASE_CA.
# With `require` + a valid CA the app connects verified; without a valid CA
# it fails loud rather than run unverified.
- key: DATABASE_SSL
scope: RUN_TIME
value: require
@@ -70,8 +71,7 @@ services:
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# Schema is migrated out-of-band (as doadmin), NOT on boot — the app user
# intentionally lacks DDL rights. Keep this false; run migrations manually.
# Schema is migrated out-of-band (as doadmin), NOT on boot.
- key: RUN_MIGRATIONS_ON_START
scope: RUN_TIME
value: "false"
@@ -81,14 +81,15 @@ services:
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# Google OAuth (optional — leave blank to disable the Google button).
- key: GOOGLE_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
value: ""
- key: GOOGLE_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
value: ""
# ── Stripe ────────────────────────────────────────────────────────────
- key: STRIPE_SECRET_KEY
@@ -99,22 +100,21 @@ services:
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# No STRIPE_*_PRICE_ID vars — prices are resolved by lookup key and
# auto-created on first checkout (lib/stripe/prices.ts). Going live only
# needs the two live secrets above + the live publishable key below.
# ── OpenAI ────────────────────────────────────────────────────────────
- key: OPENAI_API_KEY
# ── AI provider (Anthropic default; OpenAI optional) ──────────────────
- key: ANTHROPIC_API_KEY
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
- key: ANTHROPIC_MODEL
scope: RUN_TIME
value: claude-haiku-4-5
- key: OPENAI_API_KEY
scope: RUN_TIME
type: SECRET
value: ""
# ── Email (SMTP — SMTP2GO) ────────────────────────────────────────────
# The app sends mail via SMTP only (nodemailer). Email is silently skipped
# unless SMTP_HOST + SMTP_USER + SMTP_PASS are all set — password resets,
# email verification, rent/overdue/lease reminders, team invites, and
# payment links all depend on this. EMAIL_FROM is a bare address; the app
# wraps it as "Property Management Network <…>".
- key: SMTP_HOST
scope: RUN_TIME
value: mail.smtp2go.com
@@ -133,10 +133,9 @@ services:
scope: RUN_TIME
value: postmaster@propertymanagement.network
# ── Cloudflare Turnstile (site key is public; baked into the client bundle
# at image build time — keep it in sync when you build) ──
# ── Cloudflare Turnstile (site key public; baked at build time) ──
- key: NEXT_PUBLIC_TURNSTILE_SITE_KEY
scope: RUN_TIME
scope: RUN_AND_BUILD_TIME
value: 0x4AAAAAADuDQverznfv1a60
- key: TURNSTILE_SECRET_KEY
scope: RUN_TIME
@@ -165,8 +164,53 @@ services:
scope: RUN_TIME
value: https://nyc3.cdn.digitaloceanspaces.com
# ── Accounting sync (optional — per-landlord QuickBooks / Xero OAuth) ──
- key: QBO_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: ""
- key: QBO_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: ""
- key: QBO_ENVIRONMENT
scope: RUN_TIME
value: production
- key: XERO_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: ""
- key: XERO_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: ""
- key: XERO_SALES_ACCOUNT_CODE
scope: RUN_TIME
value: "200"
- key: XERO_EXPENSE_ACCOUNT_CODE
scope: RUN_TIME
value: "400"
# ── Error monitoring (Sentry — DSN is public; browser DSN baked at build) ──
- key: SENTRY_DSN
scope: RUN_TIME
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
- key: NEXT_PUBLIC_SENTRY_DSN
scope: RUN_AND_BUILD_TIME
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
- key: SENTRY_ENVIRONMENT
scope: RUN_TIME
value: production
# ── Cron (Bearer token the DO Function sends to /api/cron/*) ──
- key: CRON_SECRET
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# ── Custom domains (DNS hosted on Cloudflare — set CNAMEs there, DNS-only) ──
domains:
- domain: propertymanagement.network
type: PRIMARY
- domain: www.propertymanagement.network
type: ALIAS
+29 -27
View File
@@ -50,24 +50,16 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key
# auto-creates them on first checkout (lib/stripe/prices.ts), so going live is a
# pure key swap. Optionally pre-create the catalog: node scripts/stripe-setup.mjs
# === PAYPAL (optional — alternative subscription checkout) ===
# Lets landlords pay for their plan with PayPal alongside Stripe. Leave blank to
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com;
# keep PAYPAL_ENVIRONMENT=sandbox for testing. Create a webhook pointing to
# <APP_URL>/api/paypal/webhook and set its id as PAYPAL_WEBHOOK_ID. Generate the
# plan IDs once with `node scripts/paypal-setup-plans.mjs` and paste them below.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=sandbox
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) ===
# Get from: https://platform.openai.com/api-keys
# === AI PROVIDER (OpenAI and/or Anthropic) ===
# The active provider is chosen by an admin in Settings → System. Configure the
# key(s) for whichever provider(s) you want available; the app falls back to the
# configured one if the selected provider's key is missing.
# OpenAI — https://platform.openai.com/api-keys
OPENAI_API_KEY=sk-your-api-key
# OPENAI_MODEL=gpt-4o-mini
# Anthropic (Claude) — https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=
# ANTHROPIC_MODEL=claude-haiku-4-5 # cheapest; use claude-sonnet-5 / claude-opus-4-8 for more capability
# === EMAIL (SMTP — e.g. SMTP2GO) ===
# Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS.
@@ -87,16 +79,18 @@ QBO_ENVIRONMENT=sandbox
XERO_CLIENT_ID=
XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) ===
# Dropbox Sign: API-key auth. Set DROPBOX_SIGN_TEST_MODE=true while testing.
DROPBOX_SIGN_API_KEY=
DROPBOX_SIGN_TEST_MODE=true
# DocuSign: uses a pre-obtained access token (JWT/OAuth). Webhook: DocuSign
# Connect → <APP_URL>/api/esign/docusign/webhook ; Dropbox Sign callback →
# <APP_URL>/api/esign/dropbox_sign/webhook
DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_ACCOUNT_ID=
DOCUSIGN_BASE_URI=https://demo.docusign.net
# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# DocuSign: register ONE DocuSign app (integration key) here; each landlord then
# connects their own DocuSign account via OAuth from Settings → Integrations.
# Redirect URI to register in the DocuSign app: <APP_URL>/api/esign/docusign/callback
# DOCUSIGN_OAUTH_BASE: account-d.docusign.com (demo) or account.docusign.com (prod).
DOCUSIGN_CLIENT_ID=
DOCUSIGN_CLIENT_SECRET=
DOCUSIGN_OAUTH_BASE=account-d.docusign.com
# Dropbox Sign: no server credentials — landlords paste their own API key in the
# app and set their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
# DROPBOX_SIGN_TEST_MODE applies test mode to all outbound requests (optional).
DROPBOX_SIGN_TEST_MODE=false
# === APP ===
NEXT_PUBLIC_APP_URL=http://localhost:3000
@@ -113,6 +107,14 @@ CRON_SECRET=your-random-secret-string
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
# === ERROR MONITORING (Sentry — optional) ===
# Paste the DSN from your Sentry project (Settings → Client Keys / DSN). It's
# public (ships in the browser bundle). Sentry stays inert until this is set.
NEXT_PUBLIC_SENTRY_DSN=
# Build-time only: uploads source maps for readable stack traces. Create at
# Sentry → Settings → Auth Tokens. Keep secret; leave blank to skip upload.
SENTRY_AUTH_TOKEN=
# === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Property addresses are geocoded on save via OpenStreetMap Nominatim and shown
# on a Leaflet map (both keyless & free). Nominatim's policy requires an
+18 -25
View File
@@ -68,21 +68,6 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
# first checkout, so going live is ONLY the three values above (live keys + live
# webhook secret). Optionally pre-create the catalog: node scripts/stripe-setup.mjs
# === PAYPAL (optional — alternative subscription checkout) ===
# Landlords can pay for their plan with PayPal alongside Stripe. Leave blank to
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com and
# set PAYPAL_ENVIRONMENT=live for production. Create a webhook there pointing to
# <APP_URL>/api/paypal/webhook and put its id in PAYPAL_WEBHOOK_ID. Generate the
# plan IDs with `node scripts/paypal-setup-plans.mjs`.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=live
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) ===
OPENAI_API_KEY=sk-xxx
@@ -103,17 +88,17 @@ QBO_ENVIRONMENT=production
XERO_CLIENT_ID=
XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) ===
# Leave blank to hide/disable a provider on the lease page. Configure the
# provider callbacks to point at this app:
# Dropbox Sign callback → <APP_URL>/api/esign/dropbox_sign/webhook
# DocuSign Connect → <APP_URL>/api/esign/docusign/webhook
# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs.
DROPBOX_SIGN_API_KEY=
# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# DocuSign: register ONE DocuSign app; landlords connect their own account via
# OAuth from Settings → Integrations. Register this redirect URI in the app:
# <APP_URL>/api/esign/docusign/callback
# Use account.docusign.com in production (account-d.docusign.com for demo).
DOCUSIGN_CLIENT_ID=
DOCUSIGN_CLIENT_SECRET=
DOCUSIGN_OAUTH_BASE=account.docusign.com
# Dropbox Sign: no server credentials — landlords paste their own API key and set
# their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
DROPBOX_SIGN_TEST_MODE=false
DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_ACCOUNT_ID=
DOCUSIGN_BASE_URI=https://www.docusign.net
# === APP (NEXT_PUBLIC_* — also set as Build Variables) ===
NEXT_PUBLIC_APP_URL=https://propertymanagement.network
@@ -128,6 +113,14 @@ GOOGLE_SITE_VERIFICATION=
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
# === ERROR MONITORING (Sentry) ===
# DSN from your Sentry project (public — inlined in the browser bundle, so set
# it as a Build Variable too). Error monitoring is disabled until this is set.
NEXT_PUBLIC_SENTRY_DSN=
# Build-time secret: uploads source maps so prod stack traces are un-minified.
# Sentry → Settings → Auth Tokens. Set as a Build Variable; leave blank to skip.
SENTRY_AUTH_TOKEN=
# === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM
# tiles. No API key or billing. Nominatim REQUIRES an identifying User-Agent —
+3
View File
@@ -34,6 +34,9 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# MCP config — contains a DigitalOcean API token; keep local, never commit.
.mcp.json
# env files (can opt-in for committing if needed)
.env*
!.env.example
+12 -1
View File
@@ -94,11 +94,19 @@ docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
--build-arg NEXT_PUBLIC_SENTRY_DSN=<your-sentry-dsn> \
--build-arg SENTRY_AUTH_TOKEN=<optional-for-source-maps> \
-t $REG/property-management-network:latest .
docker push $REG/property-management-network:latest
```
> **Sentry:** the browser DSN is baked at build time, so it must be a `--build-arg`
> (setting `NEXT_PUBLIC_SENTRY_DSN` only in the dashboard won't reach the client). The
> server/edge runtimes read `SENTRY_DSN` at runtime (set in the dashboard). Both stay inert
> until a DSN is provided, so it's safe to omit until you're ready. `SENTRY_AUTH_TOKEN` is
> optional and only uploads source maps for readable stack traces.
> First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the
> `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`.
@@ -114,7 +122,10 @@ Then set every `type: SECRET` value (App → Settings → Environment Variables)
`.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`,
`BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`,
`OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`,
`SPACES_SECRET`, `CRON_SECRET` (plus the Stripe price IDs). Email sends via **SMTP
`SPACES_SECRET`, `CRON_SECRET`. Optional integrations (leave blank to keep hidden):
`QBO_CLIENT_ID/SECRET` + `XERO_CLIENT_ID/SECRET` (accounting), `DOCUSIGN_CLIENT_ID/SECRET`
(e-signature — not yet in the spec; add if used), and `SENTRY_DSN` (error monitoring —
plus the `NEXT_PUBLIC_SENTRY_DSN` build-arg above). Email sends via **SMTP
(SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without
`SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves
for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime.
+8
View File
@@ -24,9 +24,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_APP_NAME="Property Management Network"
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
# Client-side Sentry DSN — inlined into the browser bundle. Without it, only
# server/edge errors are reported (SENTRY_DSN at runtime); the browser stays inert.
ARG NEXT_PUBLIC_SENTRY_DSN
# Optional: a Sentry auth token uploads source maps for readable stack traces.
# The build still succeeds without it.
ARG SENTRY_AUTH_TOKEN
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
+8 -8
View File
@@ -32,7 +32,7 @@ Everything is **multi-tenant and team-aware**: each landlord operates on their o
**Core operations**
- 🏢 **Properties & units** — manage your whole portfolio with live occupancy tracking and a map view (addresses are auto-geocoded).
- 👥 **Tenants** — profiles, lease history, payment records, and a private **tenant portal** (token-based, no login required).
- 💵 **Rent tracking** — log payments, send **Stripe or PayPal** payment links, and auto-mark balances overdue with automatic late fees.
- 💵 **Rent tracking** — log payments, send **Stripe** payment links, and auto-mark balances overdue with automatic late fees.
- 🔧 **Maintenance** — full status workflow (Open → In Progress → Resolved), with tenant-submitted requests from the portal.
- 📄 **Leases** — expiry countdowns, automated 60/30/7-day email alerts, and **e-signature** (DocuSign / Dropbox Sign).
- 🧾 **Expenses** — categorized logging with recurring-expense support.
@@ -55,7 +55,7 @@ Everything is **multi-tenant and team-aware**: each landlord operates on their o
|---|---|
| 🌐 **Public REST API** | Versioned `/api/v1` endpoints (properties, tenants, payments, maintenance, webhooks) authenticated with Bearer **API keys**. See `/api-docs`. |
| 🪝 **Outbound webhooks / Zapier** | Subscribe to events (`tenant.created`, `payment.paid`, `maintenance.updated`, …). Deliveries are **HMAC-signed**, retried with backoff, and Zapier-compatible via the REST-hook subscribe/unsubscribe pattern. |
| 💳 **Payments** | Stripe (subscriptions + rent payment links) and PayPal. |
| 💳 **Payments** | Stripe (subscriptions + rent payment links). |
| 📚 **Accounting sync** | One-way push of income & expenses to **QuickBooks Online** or **Xero** (OAuth). |
| ✍️ **E-signature** | Send leases for signature via **DocuSign** or **Dropbox Sign**. |
| 🔑 **Auth** | Email/password and Google OAuth (Better Auth). |
@@ -73,7 +73,7 @@ Every integration is env-gated: unconfigured providers show a clean “not confi
| 🏆 **Landlord** | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) |
| ♾️ **Lifetime** | $199 once | Everything in Landlord, forever |
Billing runs through **Stripe** or **PayPal**. Stripe products/prices are resolved by stable lookup keys and auto-created on first checkout, so going live is just an API-key swap — no price IDs to wire up.
Billing runs through **Stripe**. Products/prices are resolved by stable lookup keys and auto-created on first checkout, so going live is just an API-key swap — no price IDs to wire up.
---
@@ -86,7 +86,7 @@ Billing runs through **Stripe** or **PayPal**. Stripe products/prices are resolv
| Database | PostgreSQL via **Drizzle ORM** |
| Auth | Better Auth (email/password + Google OAuth) |
| Object storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) |
| Payments | Stripe + PayPal |
| Payments | Stripe |
| AI | OpenAI (`gpt-4o-mini`) |
| Email | SMTP (SMTP2GO) |
| Maps | Leaflet + OpenStreetMap / Nominatim geocoding |
@@ -113,7 +113,7 @@ Copy the template and fill in your own values:
cp .env.example .env.local
```
`.env.local` holds your database URL, auth secret, and credentials for Stripe/PayPal, OpenAI, SMTP, and object storage. **Every variable is documented inline in `.env.example`**, and the full production reference lives in **[DIGITALOCEAN.md](DIGITALOCEAN.md)**. Never commit real secrets.
`.env.local` holds your database URL, auth secret, and credentials for Stripe, OpenAI, SMTP, and object storage. **Every variable is documented inline in `.env.example`**, and the full production reference lives in **[DIGITALOCEAN.md](DIGITALOCEAN.md)**. Never commit real secrets.
### 3. 🗄️ Run migrations
@@ -129,7 +129,7 @@ npm run db:push # push schema directly (quick local prototyping)
- **Stripe** — set the API keys, then add a webhook at `https://yourdomain.com/api/stripe/webhook` for `checkout.session.completed`, the `customer.subscription.*` events, `invoice.payment_failed`, and `payment_intent.succeeded`.
- **Email** — verify a sending domain with your SMTP provider (e.g. SMTP2GO) and set the `SMTP_*` + `EMAIL_FROM` vars.
- **Google / PayPal / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present.
- **Google / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present.
### 5. ▶️ Run locally
@@ -158,7 +158,7 @@ app/
├── api/
│ ├── v1/ # 🌐 Public REST API (Bearer API keys)
│ ├── webhooks + cron/ # 🪝 Outbound webhook delivery + scheduled jobs
│ ├── stripe/ paypal/ # 💳 Billing + payment links + provider webhooks
│ ├── stripe/ # 💳 Billing + payment links + provider webhooks
│ ├── integrations/ # 📚 QuickBooks / Xero OAuth
│ ├── esign/ # ✍️ DocuSign / Dropbox Sign
│ └── … # Properties, tenants, rent, maintenance, documents, AI
@@ -169,7 +169,7 @@ lib/
├── auth.ts account.ts # Better Auth + team/account scoping
├── storage.ts # Object storage (Spaces) with local-disk dev fallback
├── webhooks/ # Event catalog, HMAC signing, SSRF guard, delivery
├── stripe/ paypal/ # Billing clients & plans
├── stripe/ # Billing clients & plans
├── accounting/ esign/ # QuickBooks/Xero & DocuSign/Dropbox Sign
├── ai/ # OpenAI client + prompts
├── email/ # SMTP (SMTP2GO) client + HTML templates
+16 -1
View File
@@ -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">
+6
View File
@@ -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,
}: {
+6
View File
@@ -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,
}: {
+13 -19
View File
@@ -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}
+3 -19
View File
@@ -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&apos;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 ? (
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
<PortalButton />
) : null)}
)}
</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>
+29 -2
View File
@@ -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,10 +22,34 @@ 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 className="max-w-3xl mx-auto space-y-8">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-white">Integrations</h2>
<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>
@@ -35,5 +61,6 @@ export default async function IntegrationsPage({
flash={{ connected: sp.connected, error: sp.error }}
/>
</div>
</div>
)
}
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
+16 -6
View File
@@ -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,
let answer: string
try {
answer = await aiComplete({
messages: [
{ role: "system", content: context },
{ role: "user", content: question },
],
maxTokens: 1024,
})
const answer = completion.choices[0].message.content ?? ""
} 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 } })
}
+9 -7
View File
@@ -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 })
}
+9 -6
View File
@@ -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 })
+9 -6
View File
@@ -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 })
+9 -7
View File
@@ -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 })
}
+1 -1
View File
@@ -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 })
+28 -3
View File
@@ -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" })
}
}
+49
View File
@@ -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
}
+5 -3
View File
@@ -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
}
-32
View File
@@ -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 })
}
-73
View File
@@ -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 }
)
}
}
-52
View File
@@ -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
}
}
-89
View File
@@ -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 })
}
+9
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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",
},
],
}
}
+44
View File
@@ -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,
}
}
-21
View File
@@ -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
View File
@@ -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,
}))
}
+119
View File
@@ -0,0 +1,119 @@
"use client"
import { useState, useTransition } from "react"
import { toast } from "sonner"
import { Sparkles, Check, AlertTriangle } from "lucide-react"
import { setAiProviderAction } from "@/app/actions/admin"
type Provider = "openai" | "anthropic"
const LABELS: Record<Provider, string> = { openai: "OpenAI", anthropic: "Anthropic (Claude)" }
export function AiProviderToggle({
selected,
effective,
openaiConfigured,
anthropicConfigured,
openaiModel,
anthropicModel,
}: {
selected: Provider
effective: Provider
openaiConfigured: boolean
anthropicConfigured: boolean
openaiModel: string
anthropicModel: string
}) {
const [current, setCurrent] = useState<Provider>(selected)
const [pending, startTransition] = useTransition()
const configured: Record<Provider, boolean> = { openai: openaiConfigured, anthropic: anthropicConfigured }
const models: Record<Provider, string> = { openai: openaiModel, anthropic: anthropicModel }
function choose(next: Provider) {
if (next === current || pending) return
const prev = current
setCurrent(next)
startTransition(async () => {
try {
await setAiProviderAction(next)
toast.success(`AI provider set to ${LABELS[next]}`)
} catch {
setCurrent(prev) // revert optimistic change
toast.error("Couldn't switch the AI provider. Try again.")
}
})
}
// When the selected provider has no key on the server, AI falls back to the
// other configured provider (see lib/ai/provider). Surface that clearly.
const fallbackActive = effective !== current
const noneConfigured = !openaiConfigured && !anthropicConfigured
const options: Provider[] = ["openai", "anthropic"]
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Sparkles className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">AI provider</h2>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-xs text-white/40">
Choose which LLM powers all AI features (assistant, recommendations, predictions, summaries,
receipts). Applies to everyone immediately.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{options.map((p) => {
const active = current === p
return (
<button
key={p}
type="button"
onClick={() => choose(p)}
disabled={pending}
aria-pressed={active}
className={`flex items-start justify-between gap-3 rounded-xl border px-4 py-3 text-left transition disabled:opacity-60 ${
active
? "border-rose-500/40 bg-rose-500/[0.08]"
: "border-white/10 bg-white/[0.02] hover:bg-white/[0.05]"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">{LABELS[p]}</span>
{active && <Check className="h-3.5 w-3.5 text-rose-400" />}
</div>
<p className="mt-0.5 font-mono text-[11px] text-white/40 truncate">{models[p]}</p>
<p className="mt-1 text-[11px]">
{configured[p] ? (
<span className="text-emerald-400">API key configured</span>
) : (
<span className="text-amber-400">No API key on server</span>
)}
</p>
</div>
</button>
)
})}
</div>
{noneConfigured ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
No AI provider key is set on the server AI features return a 503 until{" "}
<code className="font-mono">OPENAI_API_KEY</code> or <code className="font-mono">ANTHROPIC_API_KEY</code> is configured.
</p>
) : fallbackActive ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
{LABELS[current]} has no API key on this server, so AI is temporarily running on{" "}
<span className="font-semibold">{LABELS[effective]}</span>. Add the key to use {LABELS[current]}.
</p>
) : null}
</div>
</div>
)
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import { useEffect, useState, useTransition } from "react"
import { toast } from "sonner"
import {
PenLine,
Link2,
CheckCircle2,
AlertTriangle,
KeyRound,
ChevronDown,
ExternalLink,
Loader2,
} from "lucide-react"
import { connectDropboxSign, disconnectEsignAction } from "@/app/actions/esign"
type Adapter = { id: string; label: string; kind: "oauth" | "apikey"; available: boolean }
type Conn = { provider: string; accountName: string | null; status: string; lastError: string | null }
const ERR_MSG: Record<string, string> = {
connect_failed: "Connection failed — please try again.",
invalid_state: "The connection link expired or was invalid. Please retry.",
owner_only: "Only the account owner can manage integrations.",
not_configured: "E-signature isn't enabled on this server yet.",
unknown_provider: "Unknown provider.",
use_api_key: "That provider connects with an API key, not a redirect.",
}
const LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
function StatusPill({ status }: { status: string }) {
const error = status === "error"
return (
<span
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
error ? "border-red-500/20 bg-red-500/10 text-red-400" : "border-emerald-500/20 bg-emerald-500/10 text-emerald-400"
}`}
>
{error ? <AlertTriangle className="h-3 w-3" /> : <CheckCircle2 className="h-3 w-3" />}
{error ? "Error" : "Connected"}
</span>
)
}
export function EsignIntegrations({
adapters,
connections,
isOwner,
flash,
webhookUrl,
}: {
adapters: Adapter[]
connections: Conn[]
isOwner: boolean
flash: { connected?: string; error?: string }
webhookUrl: string
}) {
const connByProvider: Record<string, Conn> = Object.fromEntries(connections.map((c) => [c.provider, c]))
const [pending, start] = useTransition()
const [busy, setBusy] = useState<string | null>(null)
const [open, setOpen] = useState<string | null>(null) // which provider's instructions are expanded
const [apiKey, setApiKey] = useState("")
const [showKeyForm, setShowKeyForm] = useState(false)
useEffect(() => {
if (flash.connected) toast.success(`Connected to ${LABEL[flash.connected] ?? flash.connected}`)
if (flash.error) toast.error(ERR_MSG[flash.error] ?? "Something went wrong")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function connectDbx() {
const key = apiKey.trim()
if (!key) return
setBusy("dropbox_sign")
start(async () => {
try {
const r = await connectDropboxSign(key)
toast.success(`Connected ${r.accountName ?? "Dropbox Sign"}`)
setApiKey("")
setShowKeyForm(false)
} catch (e) {
toast.error((e as Error).message || "Couldn't connect")
} finally {
setBusy(null)
}
})
}
function remove(id: string) {
if (!confirm(`Disconnect ${LABEL[id] ?? id}? You won't be able to send leases through it until you reconnect.`)) return
setBusy(id)
start(async () => {
try {
await disconnectEsignAction(id)
toast.success("Disconnected")
} catch {
toast.error("Couldn't disconnect")
} finally {
setBusy(null)
}
})
}
if (!isOwner) {
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6 text-sm text-white/50">
Only the account owner can connect e-signature providers.
</div>
)
}
return (
<div className="space-y-3">
{/* Intro / how it works */}
<div className="rounded-2xl border border-indigo-500/15 bg-indigo-500/[0.04] p-5">
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">Send leases for e-signature</h3>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-white/50">
Connect <span className="text-white/80">your own</span> DocuSign or Dropbox Sign account so signed leases carry
your brand and audit trail and the signing costs stay on your provider plan, not ours. Once connected, a
<span className="text-white/80"> Send for signature </span> button appears on every lease that has a document
and a tenant email.
</p>
<ol className="mt-3 space-y-1.5 text-xs text-white/50">
<li>1. Connect your provider below (one-time).</li>
<li>2. Open a lease upload the lease PDF.</li>
<li>3. Click Send via DocuSign / Dropbox Sign. The tenant signs; the status updates here automatically and the signed copy is saved back to the lease.</li>
</ol>
</div>
{adapters.map((a) => {
const conn = connByProvider[a.id]
const isBusy = pending && busy === a.id
const instructionsOpen = open === a.id
return (
<div key={a.id} className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/[0.04] text-white/70">
<PenLine className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-white">{a.label}</p>
{conn ? (
<p className="text-xs text-white/40">{conn.accountName ?? "Connected"}</p>
) : (
<p className="text-xs text-white/40">
{a.kind === "oauth" ? "Connect with your DocuSign login" : "Connect with your API key"}
</p>
)}
</div>
</div>
{conn ? (
<StatusPill status={conn.status} />
) : !a.available ? (
<span className="shrink-0 rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-400">
Not available
</span>
) : null}
</div>
{conn?.status === "error" && conn.lastError && (
<p className="mt-3 rounded-lg border border-red-500/15 bg-red-500/[0.06] px-3 py-2 text-xs text-red-300/90">{conn.lastError}</p>
)}
{/* Actions */}
<div className="mt-4 flex flex-wrap items-center gap-2">
{conn ? (
<button
onClick={() => remove(a.id)}
disabled={isBusy}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Disconnect"}
</button>
) : !a.available ? (
<p className="text-xs text-white/30">
Ask your administrator to enable {a.label} (server credentials aren&apos;t configured).
</p>
) : a.kind === "oauth" ? (
<a
href={`/api/esign/${a.id}/connect`}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
<Link2 className="h-3.5 w-3.5" /> Connect {a.label}
</a>
) : showKeyForm ? (
<div className="flex w-full flex-col gap-2 sm:flex-row">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your Dropbox Sign API key"
className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-white placeholder-white/30 outline-none focus:border-indigo-500/50"
/>
<button
onClick={connectDbx}
disabled={isBusy || !apiKey.trim()}
className="flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />} Connect
</button>
</div>
) : (
<button
onClick={() => setShowKeyForm(true)}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
<KeyRound className="h-3.5 w-3.5" /> Connect {a.label}
</button>
)}
{a.available && (
<button
onClick={() => setOpen(instructionsOpen ? null : a.id)}
className="ml-auto flex items-center gap-1 text-[11px] text-white/40 transition hover:text-white/70"
>
How to connect <ChevronDown className={`h-3 w-3 transition ${instructionsOpen ? "rotate-180" : ""}`} />
</button>
)}
</div>
{/* Instructions */}
{instructionsOpen && (
<div className="mt-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4 text-xs leading-relaxed text-white/55">
{a.id === "docusign" ? (
<ol className="space-y-1.5">
<li>
1. You need an active{" "}
<a href="https://www.docusign.com/products/electronic-signature" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
DocuSign eSignature plan <ExternalLink className="h-3 w-3" />
</a>.
</li>
<li>2. Click <span className="text-white/80">Connect DocuSign</span> above.</li>
<li>3. Log in to <span className="text-white/80">your</span> DocuSign account and click <span className="text-white/80">Allow</span> to grant access.</li>
<li>4. You&apos;ll return here connected no webhook setup needed. Signed-status updates and the completed PDF flow back automatically.</li>
</ol>
) : (
<ol className="space-y-1.5">
<li>
1. In Dropbox Sign, open{" "}
<a href="https://app.hellosign.com/account/settings/api" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
Settings API <ExternalLink className="h-3 w-3" />
</a>{" "}
and copy your <span className="text-white/80">API key</span>.
</li>
<li>2. Paste it above and click <span className="text-white/80">Connect</span>.</li>
<li>
3. In the same API settings, set your <span className="text-white/80">account callback URL</span> to:
<code className="mt-1 block overflow-x-auto rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-emerald-300">{webhookUrl}</code>
This lets us receive signed-status updates.
</li>
</ol>
)}
</div>
)}
</div>
)
})}
<p className="px-1 text-[11px] text-white/30">
Your credentials are encrypted at rest and never leave the server. We only send the leases you explicitly submit.
</p>
</div>
)
}
+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>
)
}
+20 -5
View File
@@ -1,5 +1,19 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
// we advertise only the monthly / one-time base prices that actually exist.
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
const planOffers = planOrder.map((plan) => ({
"@type": "Offer",
name: getPlanLabel(plan),
price: String(PLAN_AMOUNTS[plan]),
priceCurrency: "USD",
}))
// Mirrors the visible FAQ content in components/marketing/faq.tsx.
// Keep these in sync with that source so the JSON-LD matches what users see.
const faqs = [
@@ -43,6 +57,11 @@ const organization: Record<string, unknown> = {
name: "Property Management Network",
url: base,
logo: `${base}/logo-mark.png`,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: "support@propertymanagement.network",
},
sameAs: [
"https://twitter.com/propertymgmtnet",
"https://github.com/propertymanagement-network",
@@ -64,11 +83,7 @@ const softwareApplication: Record<string, unknown> = {
operatingSystem: "Web",
description:
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
offers: planOffers,
}
const faqPage: Record<string, unknown> = {
+21
View File
@@ -0,0 +1,21 @@
// Sentry initialization for the browser. Next.js loads this client-side
// instrumentation file automatically (Next 15.3+).
import * as Sentry from "@sentry/nextjs"
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
Sentry.init({
dsn,
// Inert until a DSN is configured.
enabled: !!dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0,
// Session Replay: record 10% of all sessions, and 100% of sessions with an
// error. Text is masked and media blocked so tenant/landlord PII isn't captured.
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true })],
})
// Instrument client-side route transitions for tracing.
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart
+23 -4
View File
@@ -1,8 +1,19 @@
// Next.js instrumentation hook — runs once when the server process starts.
// Surfaces "silently disabled" integrations at boot so a misconfigured deploy is
// obvious in the logs instead of failing quietly at runtime.
export function register() {
// Only run in the Node.js server runtime (not edge/middleware) and only in prod.
// (1) Initializes Sentry for the active runtime, and (2) surfaces "silently
// disabled" integrations at boot so a misconfigured deploy is obvious in the
// logs instead of failing quietly at runtime.
import * as Sentry from "@sentry/nextjs"
export async function register() {
// Initialize Sentry for whichever server runtime is booting.
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config")
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config")
}
// --- Startup env warnings (Node.js server runtime, production only) ---
if (process.env.NEXT_RUNTIME !== "nodejs") return
if (process.env.NODE_ENV !== "production") return
@@ -33,4 +44,12 @@ export function register() {
if (!process.env.OPENAI_API_KEY) {
warn("OPENAI_API_KEY is NOT set — AI features will error when called.")
}
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) {
warn("SENTRY DSN is NOT set — error monitoring is disabled (no crash reports).")
}
}
// Capture errors thrown in nested React Server Components, route handlers, and
// server actions and report them to Sentry.
export const onRequestError = Sentry.captureRequestError
+28 -9
View File
@@ -1,22 +1,41 @@
import crypto from "crypto"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider
// and is tamper-proof, so the callback can't be forged/CSRF'd.
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider,
// a random nonce (bound to a cookie by the connect route for CSRF protection),
// and an issued-at timestamp so a leaked state can't be replayed indefinitely.
export function signState(data: { ownerId: string; provider: string }): string {
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
// Short-lived httpOnly cookie the connect route sets and the callback verifies
// against the state's nonce (binds the OAuth round-trip to the initiating browser).
export const OAUTH_NONCE_COOKIE = "acct_oauth_nonce"
// No insecure fallback: signing/verifying state without the real secret would
// let anyone forge a state for any owner, so we fail closed (mirrors lib/crypto.ts).
function secret(): string {
const s = process.env.BETTER_AUTH_SECRET
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
return s
}
export type OAuthState = { ownerId: string; provider: string; nonce: string }
export function signState(data: OAuthState): string {
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): { ownerId: string; provider: string } | null {
export function verifyState(state: string): OAuthState | null {
const [payload, sig] = state.split(".")
if (!payload || !sig) return null
const expect = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as OAuthState & { iat?: number }
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
} catch {
return null
}
+1
View File
@@ -12,6 +12,7 @@ export type AdminAction =
| "delete_user"
| "resend_verification"
| "maintenance_mode"
| "ai_provider"
/**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating
+15
View File
@@ -0,0 +1,15 @@
import Anthropic from "@anthropic-ai/sdk"
// Lazily construct the Anthropic client so `next build` does NOT require
// ANTHROPIC_API_KEY — it's only needed at runtime when the admin selects the
// Anthropic (Claude) provider for AI features. Mirrors lib/ai/client.ts.
let _anthropic: Anthropic | null = null
export function getAnthropic(): Anthropic {
if (!_anthropic) {
const key = process.env.ANTHROPIC_API_KEY
if (!key) throw new Error("ANTHROPIC_API_KEY is not set")
_anthropic = new Anthropic({ apiKey: key })
}
return _anthropic
}
+11
View File
@@ -1,5 +1,16 @@
import OpenAI from "openai"
// True when the server has an AI provider key (OpenAI OR Anthropic). AI routes
// check this up front and return a friendly 503 instead of throwing, matching
// how the other optional integrations (Stripe / SMTP / Turnstile) degrade when
// unconfigured. The active provider is chosen by an admin (see lib/ai/provider).
export function aiConfigured(): boolean {
return Boolean(process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY)
}
export const AI_UNCONFIGURED_ERROR =
"AI features aren't configured on this server (no OpenAI or Anthropic API key). Ask your administrator to enable them."
// Lazily construct the OpenAI client so `next build` does NOT require
// OPENAI_API_KEY — it's only needed at runtime. Call sites keep using
// `openai.xxx` unchanged; the Proxy builds the real client on first access.
+146
View File
@@ -0,0 +1,146 @@
import { eq } from "drizzle-orm"
import type OpenAI from "openai"
import { db } from "@/lib/db"
import { app_settings } from "@/lib/db/schema"
import { openai } from "@/lib/ai/client"
import { getAnthropic } from "@/lib/ai/anthropic"
// ============================================================================
// AI provider abstraction — one call site for every AI feature, backed by
// EITHER OpenAI or Anthropic (Claude). The active provider is chosen by an admin
// in Settings → System (persisted in app_settings), falling back to whichever
// provider actually has an API key configured on the server.
// ============================================================================
export type AiProvider = "openai" | "anthropic"
export type AiRole = "system" | "user" | "assistant"
export type AiMessage = { role: AiRole; content: string }
export const AI_PROVIDER_KEY = "ai_provider"
// Models are env-overridable. Both default to each provider's cheapest tier to
// keep token spend low: OpenAI gpt-4o-mini, Anthropic Claude Haiku 4.5 ($1/$5
// per 1M). Pin a stronger model via OPENAI_MODEL / ANTHROPIC_MODEL if desired.
export const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini"
export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5"
export function openaiConfigured(): boolean {
return Boolean(process.env.OPENAI_API_KEY)
}
export function anthropicConfigured(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY)
}
function isProvider(v: unknown): v is AiProvider {
return v === "openai" || v === "anthropic"
}
/** The admin-selected provider (defaults to openai). Fails safe to openai. */
export async function getAiProvider(): Promise<AiProvider> {
try {
const row = await db.query.app_settings.findFirst({
where: eq(app_settings.key, AI_PROVIDER_KEY),
})
const v = (row?.value as { provider?: string } | null)?.provider
return isProvider(v) ? v : "openai"
} catch {
return "openai"
}
}
/** Persist the admin's provider choice. Admin-gated by the calling action. */
export async function setAiProvider(provider: AiProvider): Promise<void> {
await db
.insert(app_settings)
.values({ key: AI_PROVIDER_KEY, value: { provider } })
.onConflictDoUpdate({
target: app_settings.key,
set: { value: { provider }, updated_at: new Date().toISOString() },
})
}
/**
* The provider actually used for a request: the selected one, unless it has no
* API key on the server and the other provider does — then we fall back so AI
* features keep working after a provider switch even if the key isn't set yet.
*/
function resolveEffective(selected: AiProvider): AiProvider {
if (selected === "anthropic" && !anthropicConfigured() && openaiConfigured()) return "openai"
if (selected === "openai" && !openaiConfigured() && anthropicConfigured()) return "anthropic"
return selected
}
/** Everything the admin UI needs to render the provider picker. */
export async function aiProviderStatus() {
const selected = await getAiProvider()
return {
selected,
effective: resolveEffective(selected),
openaiConfigured: openaiConfigured(),
anthropicConfigured: anthropicConfigured(),
openaiModel: OPENAI_MODEL,
anthropicModel: ANTHROPIC_MODEL,
}
}
/** Strip a ```json fenced code block, if the model wrapped its JSON in one. */
function stripFences(s: string): string {
return s
.replace(/^\s*```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim()
}
/**
* Provider-agnostic single-shot completion. Returns the model's text output.
*
* `json: true` asks for a JSON object (OpenAI uses response_format; both
* providers rely on the prompt saying "JSON only") and strips any code fences
* so the caller can `JSON.parse` the result directly.
*/
export async function aiComplete(opts: {
messages: AiMessage[]
maxTokens?: number
json?: boolean
}): Promise<string> {
const provider = resolveEffective(await getAiProvider())
const maxTokens = opts.maxTokens ?? 1024
let text: string
if (provider === "anthropic") {
// Anthropic takes a top-level `system`; the rest are user/assistant turns.
const system = opts.messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n\n")
const convo = opts.messages
.filter((m) => m.role !== "system")
.map((m) => ({ role: (m.role === "assistant" ? "assistant" : "user") as "assistant" | "user", content: m.content }))
if (convo.length === 0) convo.push({ role: "user", content: system || "Continue." })
const res = await getAnthropic().messages.create({
model: ANTHROPIC_MODEL,
max_tokens: maxTokens,
...(system ? { system } : {}),
messages: convo,
})
text = res.content.map((b) => (b.type === "text" ? b.text : "")).join("")
} else {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = opts.messages.map((m) =>
m.role === "system"
? { role: "system", content: m.content }
: m.role === "assistant"
? { role: "assistant", content: m.content }
: { role: "user", content: m.content }
)
const res = await openai.chat.completions.create({
model: OPENAI_MODEL,
max_tokens: maxTokens,
messages,
...(opts.json ? { response_format: { type: "json_object" as const } } : {}),
})
text = res.choices[0]?.message?.content ?? ""
}
return opts.json ? stripFences(text) : text
}
+1
View File
@@ -254,6 +254,7 @@ export function getEnvHealth() {
"STRIPE_WEBHOOK_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"SMTP_HOST",
"SMTP_USER",
"GOOGLE_CLIENT_ID",
@@ -0,0 +1,17 @@
CREATE TABLE "esign_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text,
"expires_at" timestamp with time zone,
"account_id" text,
"base_uri" text,
"account_name" text,
"status" text DEFAULT 'active' NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "esign_connections" ADD CONSTRAINT "esign_connections_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1782994066547,
"tag": "0009_amusing_blackheart",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1783017593260,
"tag": "0010_esign_connections",
"breakpoints": true
}
]
}
+26
View File
@@ -631,6 +631,32 @@ export const signature_requests = pgTable("signature_requests", {
updated_at: updatedAt(),
})
// ============================================================
// E-SIGN CONNECTIONS (per-landlord DocuSign OAuth / Dropbox Sign API key)
// ============================================================
// One row per (owner, provider). Each landlord connects THEIR OWN e-signature
// account, so leases are sent from their brand with their audit trail. DocuSign
// uses OAuth (access + refresh tokens); Dropbox Sign uses an API key stored in
// `access_token`. All secrets are AES-256-GCM encrypted (see lib/crypto.ts).
export const esign_connections = pgTable("esign_connections", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
provider: text("provider").$type<"docusign" | "dropbox_sign">().notNull(),
access_token: text("access_token").notNull(), // encrypted (DocuSign access token / Dropbox Sign API key)
refresh_token: text("refresh_token"), // encrypted (DocuSign only)
expires_at: tstz("expires_at"),
// DocuSign account id + base uri from /oauth/userinfo (null for Dropbox Sign).
account_id: text("account_id"),
base_uri: text("base_uri"),
account_name: text("account_name"),
status: text("status").$type<"active" | "error" | "revoked">().notNull().default("active"),
last_error: text("last_error"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// WEBHOOK ENDPOINTS (outbound webhooks / Zapier integration)
// ============================================================
+104
View File
@@ -0,0 +1,104 @@
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { esign_connections } from "@/lib/db/schema"
import { encrypt, decrypt } from "@/lib/crypto"
import { getAdapter } from "./registry"
import type { ESignCredentials, ESignProvider, ESignTokens } from "./types"
// Per-owner e-sign connection storage + credential resolution. Mirrors
// lib/accounting/index.ts: tokens are AES-256-GCM encrypted at rest, decrypted
// on demand, and DocuSign access tokens are transparently refreshed near expiry.
/** Upsert an encrypted connection for (owner, provider). */
export async function saveEsignConnection(ownerId: string, provider: ESignProvider, tokens: ESignTokens) {
const values = {
user_id: ownerId,
provider,
access_token: encrypt(tokens.accessToken),
refresh_token: tokens.refreshToken ? encrypt(tokens.refreshToken) : null,
expires_at: tokens.expiresAt,
account_id: tokens.accountId,
base_uri: tokens.baseUri,
account_name: tokens.accountName,
status: "active" as const,
last_error: null,
}
const existing = await db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
columns: { id: true },
})
if (existing) {
await db
.update(esign_connections)
.set({ ...values, updated_at: new Date().toISOString() })
.where(eq(esign_connections.id, existing.id))
} else {
await db.insert(esign_connections).values(values)
}
}
export async function getEsignConnection(ownerId: string, provider: ESignProvider) {
return db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
})
}
/** Owner-facing list — never leaks tokens. */
export async function listEsignConnections(ownerId: string) {
const rows = await db.query.esign_connections.findMany({ where: eq(esign_connections.user_id, ownerId) })
return rows.map((r) => ({
provider: r.provider,
accountName: r.account_name,
status: r.status,
lastError: r.last_error,
}))
}
export async function disconnectEsign(ownerId: string, provider: ESignProvider) {
await db
.delete(esign_connections)
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}
/**
* Resolve ready-to-use credentials for a connected account, refreshing the
* DocuSign access token first if it's near expiry. Returns null when the owner
* hasn't connected this provider.
*/
export async function resolveEsignCreds(ownerId: string, provider: ESignProvider): Promise<ESignCredentials | null> {
const conn = await getEsignConnection(ownerId, provider)
if (!conn || conn.status === "revoked") return null
let accessToken = decrypt(conn.access_token)
const refreshToken = conn.refresh_token ? decrypt(conn.refresh_token) : null
let accountId = conn.account_id
let baseUri = conn.base_uri
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 5 * 60_000
if (nearExpiry && refreshToken) {
const adapter = getAdapter(provider)
if (adapter) {
const next = await adapter.refresh(refreshToken)
// Account id / base uri are stable across refresh — keep the stored ones.
await saveEsignConnection(ownerId, provider, {
...next,
accountId: conn.account_id,
baseUri: conn.base_uri,
accountName: conn.account_name,
})
accessToken = next.accessToken
accountId = conn.account_id
baseUri = conn.base_uri
}
}
return { provider, accessToken, refreshToken, accountId, baseUri }
}
/** Flag a connection as errored (e.g. after a failed send/refresh). */
export async function markEsignError(ownerId: string, provider: ESignProvider, message: string) {
await db
.update(esign_connections)
.set({ status: "error", last_error: message.slice(0, 500), updated_at: new Date().toISOString() })
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}
+135 -27
View File
@@ -1,44 +1,139 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
import type { ESignAdapter, ESignCredentials, ESignTokens, SendParams } from "./types"
import { esignRedirectUri } from "./types"
// DocuSign eSignature REST API. Uses a pre-obtained access token (via JWT grant
// or OAuth) — set DOCUSIGN_ACCESS_TOKEN / DOCUSIGN_ACCOUNT_ID / DOCUSIGN_BASE_URI.
// Docs: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
const ACCESS_TOKEN = process.env.DOCUSIGN_ACCESS_TOKEN ?? ""
const ACCOUNT_ID = process.env.DOCUSIGN_ACCOUNT_ID ?? ""
const BASE_URI = (process.env.DOCUSIGN_BASE_URI ?? "https://demo.docusign.net").replace(/\/+$/, "")
// DocuSign eSignature via per-landlord OAuth (Authorization Code Grant).
// The OPERATOR registers one DocuSign app and sets these; each LANDLORD then
// connects their own DocuSign account through it.
// DOCUSIGN_CLIENT_ID / DOCUSIGN_CLIENT_SECRET — the app's integration key + secret
// DOCUSIGN_OAUTH_BASE — "account-d.docusign.com" (demo) or "account.docusign.com" (prod)
const CLIENT_ID = process.env.DOCUSIGN_CLIENT_ID ?? ""
const CLIENT_SECRET = process.env.DOCUSIGN_CLIENT_SECRET ?? ""
const OAUTH_BASE = (process.env.DOCUSIGN_OAUTH_BASE ?? "account-d.docusign.com").replace(/^https?:\/\//, "").replace(/\/+$/, "")
function basicAuth() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
}
function extOf(name: string): string {
const e = name.split(".").pop()?.toLowerCase()
return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf"
}
/** Map a DocuSign envelope/event status string to one of our terminal statuses. */
function mapStatus(raw: string): "signed" | "declined" | "voided" | null {
const s = raw.toLowerCase()
if (s.includes("completed") || s.includes("signed")) return "signed"
if (s.includes("declined")) return "declined"
if (s.includes("voided")) return "voided"
return null
}
async function tokenRequest(form: Record<string, string>): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
const res = await fetch(`https://${OAUTH_BASE}/oauth/token`, {
method: "POST",
headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams(form),
})
if (!res.ok) throw new Error(`DocuSign token error ${res.status}: ${(await res.text()).slice(0, 300)}`)
return res.json()
}
async function userInfo(accessToken: string): Promise<{ accountId: string | null; baseUri: string | null; accountName: string | null }> {
const res = await fetch(`https://${OAUTH_BASE}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
})
if (!res.ok) throw new Error(`DocuSign userinfo ${res.status}`)
const j = (await res.json()) as { accounts?: { account_id: string; base_uri: string; account_name: string; is_default: boolean }[] }
const acct = j.accounts?.find((a) => a.is_default) ?? j.accounts?.[0]
return { accountId: acct?.account_id ?? null, baseUri: acct?.base_uri ?? null, accountName: acct?.account_name ?? null }
}
/** REST API base for the envelopes API, e.g. https://na3.docusign.net/restapi/v2.1/accounts/<id> */
function apiBase(creds: ESignCredentials): string {
return `${(creds.baseUri ?? "").replace(/\/+$/, "")}/restapi/v2.1/accounts/${creds.accountId}`
}
export const docusign: ESignAdapter = {
id: "docusign",
label: "DocuSign",
configured: () => Boolean(ACCESS_TOKEN && ACCOUNT_ID),
kind: "oauth",
available: () => Boolean(CLIENT_ID && CLIENT_SECRET),
async send({ document, documentName, signerEmail, signerName, subject }: SendParams) {
getAuthUrl(state) {
const p = new URLSearchParams({
response_type: "code",
// `extended` is required to receive a refresh token.
scope: "signature extended",
client_id: CLIENT_ID,
redirect_uri: esignRedirectUri("docusign"),
state,
})
return `https://${OAUTH_BASE}/oauth/auth?${p.toString()}`
},
async exchangeCode(code): Promise<ESignTokens> {
const t = await tokenRequest({ grant_type: "authorization_code", code })
const info = await userInfo(t.access_token)
return {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
accountId: info.accountId,
baseUri: info.baseUri,
accountName: info.accountName,
}
},
async refresh(refreshToken): Promise<ESignTokens> {
const t = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken })
// Account id / base uri are stable across refreshes; the resolver re-attaches them.
return {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
accountId: null,
baseUri: null,
accountName: null,
}
},
async connectApiKey(): Promise<ESignTokens> {
throw new Error("DocuSign connects via OAuth, not an API key")
},
async send(creds, p: SendParams) {
const envelope = {
emailSubject: subject,
emailSubject: p.subject,
status: "sent",
documents: [{ documentBase64: document.toString("base64"), name: documentName, fileExtension: extOf(documentName), documentId: "1" }],
documents: [{ documentBase64: p.document.toString("base64"), name: p.documentName, fileExtension: extOf(p.documentName), documentId: "1" }],
recipients: {
signers: [
{
email: signerEmail,
name: signerName,
email: p.signerEmail,
name: p.signerName,
recipientId: "1",
routingOrder: "1",
// Default sign placement (bottom of page 1). Use a template/anchor
// string for precise field placement in production.
tabs: { signHereTabs: [{ documentId: "1", pageNumber: "1", xPosition: "100", yPosition: "650" }] },
},
],
},
// Envelope-level Connect: DocuSign pings our webhook on completion so we
// pull the authoritative status. No account-level Connect config needed.
eventNotification: {
url: p.webhookUrl,
loggingEnabled: "true",
requireAcknowledgment: "true",
envelopeEvents: [
{ envelopeEventStatusCode: "completed" },
{ envelopeEventStatusCode: "declined" },
{ envelopeEventStatusCode: "voided" },
],
eventData: { version: "restv2.1" },
},
}
const res = await fetch(`${BASE_URI}/restapi/v2.1/accounts/${ACCOUNT_ID}/envelopes`, {
const res = await fetch(`${apiBase(creds)}/envelopes`, {
method: "POST",
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "Content-Type": "application/json" },
headers: { Authorization: `Bearer ${creds.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(envelope),
})
if (!res.ok) throw new Error(`DocuSign ${res.status}: ${(await res.text()).slice(0, 300)}`)
@@ -47,19 +142,32 @@ export const docusign: ESignAdapter = {
return { externalId: j.envelopeId }
},
parseWebhook(body): WebhookResult | null {
// DocuSign Connect (JSON format) payload.
peekExternalId(body): string | null {
try {
const j = JSON.parse(body) as { event?: string; data?: { envelopeId?: string; envelopeSummary?: { status?: string } } }
const externalId = j.data?.envelopeId
const status = (j.data?.envelopeSummary?.status ?? j.event ?? "").toLowerCase()
if (!externalId) return null
if (status.includes("completed") || status.includes("signed")) return { externalId, status: "signed" }
if (status.includes("declined")) return { externalId, status: "declined" }
if (status.includes("voided")) return { externalId, status: "voided" }
return null
const j = JSON.parse(body) as { data?: { envelopeId?: string } }
return j.data?.envelopeId ?? null
} catch {
return null
}
},
// We never trust the webhook body's status. Instead we fetch the envelope from
// DocuSign with the owner's own OAuth token — a forged webhook can at most make
// us re-read the real status, never fabricate a "signed".
async verifyAndGetStatus(creds, externalId) {
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}`, {
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/json" },
})
if (!res.ok) return null
const j = (await res.json()) as { status?: string }
return j.status ? mapStatus(j.status) : null
},
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}/documents/combined`, {
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/pdf" },
})
if (!res.ok) return null
return Buffer.from(await res.arrayBuffer())
},
}
+91 -30
View File
@@ -1,30 +1,83 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
import crypto from "crypto"
import type { ESignAdapter, ESignTokens, SendParams } from "./types"
// Dropbox Sign (formerly HelloSign). API-key auth. Docs:
// https://developers.hellosign.com/api/reference/operation/signatureRequestSend/
const API_KEY = process.env.DROPBOX_SIGN_API_KEY ?? ""
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
// Dropbox Sign (formerly HelloSign). Per-landlord API-key auth — each landlord
// pastes their own API key (no platform app credentials needed). Docs:
// https://developers.hellosign.com/api/reference/
const BASE = "https://api.hellosign.com/v3"
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
function auth() {
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64")
function authFor(apiKey: string) {
return "Basic " + Buffer.from(`${apiKey}:`).toString("base64")
}
/** Verify the `event_hash` (hex HMAC-SHA256 of event_time+event_type, key = API key). */
function verifyEventHash(apiKey: string, ev?: { event_type?: string; event_time?: string; event_hash?: string }): boolean {
if (!apiKey || !ev?.event_type || !ev?.event_time || !ev?.event_hash) return false
const expected = crypto.createHmac("sha256", apiKey).update(ev.event_time + ev.event_type).digest("hex")
const a = Buffer.from(ev.event_hash)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
type DbxEvent = {
event?: { event_type?: string; event_time?: string; event_hash?: string }
signature_request?: { signature_request_id?: string }
}
function parseBody(body: string): DbxEvent | null {
try {
// Dropbox Sign posts multipart form-data with a `json` field (or raw JSON).
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
return JSON.parse(m ? m[1] : body)
} catch {
return null
}
}
export const dropboxSign: ESignAdapter = {
id: "dropbox_sign",
label: "Dropbox Sign",
configured: () => Boolean(API_KEY),
kind: "apikey",
available: () => true, // landlord brings their own key; no operator setup required
async send({ document, documentName, signerEmail, signerName, subject, message }: SendParams) {
getAuthUrl(): string {
throw new Error("Dropbox Sign connects with an API key, not OAuth")
},
async exchangeCode(): Promise<ESignTokens> {
throw new Error("Dropbox Sign connects with an API key, not OAuth")
},
async refresh(): Promise<ESignTokens> {
throw new Error("Dropbox Sign API keys don't expire")
},
async connectApiKey(apiKey): Promise<ESignTokens> {
const key = apiKey.trim()
if (!key) throw new Error("Enter your Dropbox Sign API key")
const res = await fetch(`${BASE}/account`, { headers: { Authorization: authFor(key) } })
if (res.status === 401 || res.status === 403) throw new Error("That API key was rejected by Dropbox Sign")
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: could not validate the API key`)
const j = (await res.json()) as { account?: { email_address?: string } }
return {
accessToken: key,
refreshToken: null,
expiresAt: null,
accountId: null,
baseUri: null,
accountName: j.account?.email_address ?? "Dropbox Sign account",
}
},
async send(creds, p: SendParams) {
const fd = new FormData()
fd.append("subject", subject)
fd.append("message", message)
fd.append("subject", p.subject)
fd.append("message", p.message)
fd.append("test_mode", TEST_MODE)
fd.append("signers[0][email_address]", signerEmail)
fd.append("signers[0][name]", signerName)
fd.append("file[0]", new Blob([new Uint8Array(document)], { type: "application/pdf" }), documentName)
fd.append("signers[0][email_address]", p.signerEmail)
fd.append("signers[0][name]", p.signerName)
fd.append("file[0]", new Blob([new Uint8Array(p.document)], { type: "application/pdf" }), p.documentName)
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: auth() }, body: fd })
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: authFor(creds.accessToken) }, body: fd })
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: ${(await res.text()).slice(0, 300)}`)
const j = (await res.json()) as { signature_request?: { signature_request_id?: string } }
const id = j.signature_request?.signature_request_id
@@ -32,22 +85,30 @@ export const dropboxSign: ESignAdapter = {
return { externalId: id }
},
parseWebhook(body): WebhookResult | null {
// Dropbox Sign posts multipart form-data with a `json` field.
let event: { event?: { event_type?: string }; signature_request?: { signature_request_id?: string } }
try {
// Extract the JSON payload whether sent raw or as a form field.
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
event = JSON.parse(m ? m[1] : body)
} catch {
peekExternalId(body): string | null {
return parseBody(body)?.signature_request?.signature_request_id ?? null
},
async verifyAndGetStatus(creds, _externalId, body): Promise<"signed" | "declined" | "voided" | null> {
const ev = parseBody(body)
if (!ev || !verifyEventHash(creds.accessToken, ev.event)) return null
switch (ev.event?.event_type) {
case "signature_request_all_signed":
return "signed"
case "signature_request_declined":
return "declined"
case "signature_request_canceled":
return "voided"
default:
return null
}
const type = event.event?.event_type
const externalId = event.signature_request?.signature_request_id
if (!externalId || !type) return null
if (type === "signature_request_all_signed") return { externalId, status: "signed" }
if (type === "signature_request_declined") return { externalId, status: "declined" }
if (type === "signature_request_canceled") return { externalId, status: "voided" }
return null
},
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
const res = await fetch(`${BASE}/signature_request/files/${externalId}?file_type=pdf`, {
headers: { Authorization: authFor(creds.accessToken) },
})
if (!res.ok) return null
return Buffer.from(await res.arrayBuffer())
},
}
+73 -30
View File
@@ -1,42 +1,48 @@
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases, signature_requests } from "@/lib/db/schema"
import { readFile } from "@/lib/storage"
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
import { readFile, saveBuffer } from "@/lib/storage"
import { getAdapter } from "./registry"
import { resolveEsignCreds, markEsignError } from "./credentials"
import type { ESignProvider } from "./types"
export type { ESignProvider } from "./types"
export { getAdapter, listEsignAdapters } from "./registry"
export {
resolveEsignCreds,
listEsignConnections,
getEsignConnection,
saveEsignConnection,
disconnectEsign,
} from "./credentials"
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
const FILES_PREFIX = "/api/files/"
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
export function listAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({ id, label: ADAPTERS[id].label, configured: ADAPTERS[id].configured() }))
}
export function anyEsignConfigured(): boolean {
return listAdapters().some((a) => a.configured)
/** The webhook URL a provider should call back — used for DocuSign envelope-level Connect. */
function webhookUrl(provider: ESignProvider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/esign/${provider}/webhook`
}
/** Read the lease's stored document. Only /api/files keys are allowed (no SSRF). */
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
const prefix = "/api/files/"
if (documentUrl.startsWith(prefix)) {
const key = documentUrl.slice(prefix.length)
if (!documentUrl.startsWith(FILES_PREFIX)) {
throw new Error("Lease document must be an uploaded file")
}
const key = documentUrl.slice(FILES_PREFIX.length)
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
}
const res = await fetch(documentUrl)
if (!res.ok) throw new Error("Could not fetch the lease document")
return { bytes: Buffer.from(await res.arrayBuffer()), name: documentUrl.split("/").pop()?.split("?")[0] ?? "lease.pdf" }
}
/**
* Send a lease for signature through the owner's OWN connected account.
* Requires the provider to be connected (per-user OAuth / API key).
*/
export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
const adapter = getAdapter(provider)
if (!adapter) throw new Error("Unknown provider")
if (!adapter.configured()) throw new Error(`${adapter.label} is not configured`)
const creds = await resolveEsignCreds(ownerId, provider)
if (!creds) throw new Error(`Connect your ${adapter.label} account in Settings → Integrations first`)
const lease = await db.query.leases.findFirst({
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)),
@@ -51,13 +57,14 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
const { bytes, name } = await getDocumentBytes(lease.document_url)
try {
const { externalId } = await adapter.send({
const { externalId } = await adapter.send(creds, {
document: bytes,
documentName: name,
signerEmail: email,
signerName,
subject: "Please sign your lease agreement",
message: "Your landlord has sent your lease agreement for electronic signature.",
webhookUrl: webhookUrl(provider),
})
const [row] = await db
.insert(signature_requests)
@@ -66,6 +73,7 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
return row
} catch (e) {
const msg = (e as Error).message.slice(0, 500)
await markEsignError(ownerId, provider, msg)
await db
.insert(signature_requests)
.values({ user_id: ownerId, lease_id: leaseId, provider, status: "error", signer_email: email, signer_name: signerName, document_name: name, last_error: msg })
@@ -80,18 +88,53 @@ export async function listRequestsForLease(ownerId: string, leaseId: string) {
})
}
/** Update a request's status from an inbound provider webhook. */
/**
* Process an inbound provider webhook. The body is UNTRUSTED: we use it only to
* find which signature request (and therefore which owner + credentials) it
* concerns, then authenticate the event via the adapter (DocuSign pull-verify /
* Dropbox HMAC) before updating status and archiving the signed document.
*/
export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
const adapter = getAdapter(provider)
if (!adapter) return
const result = adapter.parseWebhook(body, headers)
if (!result) return
const externalId = adapter.peekExternalId(body)
if (!externalId) return
const reqRow = await db.query.signature_requests.findFirst({
where: eq(signature_requests.external_id, externalId),
columns: { id: true, user_id: true, status: true },
})
if (!reqRow) return
const creds = await resolveEsignCreds(reqRow.user_id, provider as ESignProvider)
if (!creds) return
const status = await adapter.verifyAndGetStatus(creds, externalId, body, headers)
if (!status) return
await db
.update(signature_requests)
.set({
status: result.status,
completed_at: result.status === "signed" ? new Date().toISOString() : null,
status,
completed_at: status === "signed" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
})
.where(eq(signature_requests.external_id, result.externalId))
.where(eq(signature_requests.id, reqRow.id))
// Archive the executed document so the landlord can download the signed copy.
if (status === "signed") {
try {
const bytes = await adapter.getSignedDocument(creds, externalId)
if (bytes && bytes.length) {
const { key } = await saveBuffer(bytes, { userId: reqRow.user_id, scope: "esign", ext: "pdf" })
await db
.update(signature_requests)
.set({ signed_document_url: `${FILES_PREFIX}${key}` })
.where(eq(signature_requests.id, reqRow.id))
}
} catch {
// Best-effort — status is already recorded.
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
// Adapter registry — dependency-free (no db) so it can be imported anywhere,
// including the credential resolver, without creating import cycles.
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
/** Providers the platform can offer, with their connect style + availability. */
export function listEsignAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({
id,
label: ADAPTERS[id].label,
kind: ADAPTERS[id].kind,
available: ADAPTERS[id].available(),
}))
}
+39
View File
@@ -0,0 +1,39 @@
import crypto from "crypto"
// Signed OAuth `state` for the e-sign connect flow — carries the initiating
// owner + provider + a random nonce (bound to a cookie by the connect route),
// plus an issued-at so a leaked state can't be replayed. Mirrors the hardened
// accounting OAuth state; fails closed if BETTER_AUTH_SECRET is missing.
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
export const ESIGN_NONCE_COOKIE = "esign_oauth_nonce"
function secret(): string {
const s = process.env.BETTER_AUTH_SECRET
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
return s
}
export type EsignOAuthState = { ownerId: string; provider: string; nonce: string }
export function signState(data: EsignOAuthState): string {
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): EsignOAuthState | null {
const [payload, sig] = state.split(".")
if (!payload || !sig) return null
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try {
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as EsignOAuthState & { iat?: number }
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
} catch {
return null
}
}
+67 -11
View File
@@ -1,5 +1,27 @@
export type ESignProvider = "docusign" | "dropbox_sign"
export type ESignStatus = "signed" | "declined" | "voided"
/** Result of connecting an account (OAuth exchange or API-key validation). */
export interface ESignTokens {
accessToken: string
refreshToken: string | null
/** ISO expiry of the access token, or null (API keys don't expire). */
expiresAt: string | null
accountId: string | null
baseUri: string | null
accountName: string | null
}
/** Decrypted, ready-to-use credentials for a single connected account. */
export interface ESignCredentials {
provider: ESignProvider
accessToken: string
refreshToken?: string | null
accountId?: string | null
baseUri?: string | null
}
export interface SendParams {
document: Buffer
documentName: string
@@ -7,20 +29,54 @@ export interface SendParams {
signerName: string
subject: string
message: string
}
export interface WebhookResult {
externalId: string
status: "signed" | "declined" | "voided"
/** Our callback the provider should ping on status changes (DocuSign envelope-level Connect). */
webhookUrl: string
}
export interface ESignAdapter {
id: ESignProvider
label: string
/** True when this provider's credentials are configured in env. */
configured(): boolean
/** Send a document for signature; returns the provider's request/envelope id. */
send(p: SendParams): Promise<{ externalId: string }>
/** Parse an inbound webhook body into a status update (or null to ignore). */
parseWebhook(body: string, headers: Headers): WebhookResult | null
/** "oauth" → connect via redirect; "apikey" → connect by pasting a key. */
kind: "oauth" | "apikey"
/**
* True when the platform can offer this provider. OAuth providers need the
* operator's app credentials (client id/secret); API-key providers are always
* available because the landlord brings their own key.
*/
available(): boolean
// ── OAuth providers (DocuSign) ────────────────────────────────────────────
getAuthUrl(state: string): string
exchangeCode(code: string): Promise<ESignTokens>
refresh(refreshToken: string): Promise<ESignTokens>
// ── API-key providers (Dropbox Sign) ──────────────────────────────────────
/** Validate a pasted API key and return a token set to store. */
connectApiKey(apiKey: string): Promise<ESignTokens>
// ── Common ────────────────────────────────────────────────────────────────
/** Send a document for signature; returns the provider's envelope/request id. */
send(creds: ESignCredentials, params: SendParams): Promise<{ externalId: string }>
/** Extract the external id from an inbound (still UNVERIFIED) webhook body, for owner lookup. */
peekExternalId(body: string): string | null
/**
* Authenticate an inbound webhook and return the authoritative status.
* DocuSign pull-verifies by fetching the envelope with the owner's token;
* Dropbox Sign HMAC-verifies the body with the account API key. Returns null
* if the event isn't authentic or isn't a terminal status we track.
*/
verifyAndGetStatus(
creds: ESignCredentials,
externalId: string,
body: string,
headers: Headers
): Promise<ESignStatus | null>
/** Download the completed/executed document, or null if unavailable. */
getSignedDocument(creds: ESignCredentials, externalId: string): Promise<Buffer | null>
}
/** The OAuth callback URL for a provider (must match what's registered in the provider app). */
export function esignRedirectUri(provider: ESignProvider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/esign/${provider}/callback`
}
-123
View File
@@ -1,123 +0,0 @@
import { paypalFetch } from "./client"
// We encode the app user id + target plan into PayPal's `custom_id` so webhooks
// and the return handler can resolve who/what a subscription or order is for,
// without trusting query params. Format: "<userId>:<plan>".
export function encodeCustomId(userId: string, plan: string): string {
return `${userId}:${plan}`
}
export function decodeCustomId(customId: string | null | undefined): { userId: string; plan: string } | null {
if (!customId) return null
const idx = customId.lastIndexOf(":")
if (idx <= 0) return null
return { userId: customId.slice(0, idx), plan: customId.slice(idx + 1) }
}
function approveUrl(links: Array<{ rel: string; href: string }> | undefined): string | undefined {
return links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href
}
const BRAND = "Property Management Network"
/** Create a recurring subscription; returns its id + the PayPal approval URL. */
export async function createSubscription(params: {
planId: string
userId: string
plan: string
email?: string | null
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v1/billing/subscriptions", {
method: "POST",
body: JSON.stringify({
plan_id: params.planId,
custom_id: encodeCustomId(params.userId, params.plan),
subscriber: params.email ? { email_address: params.email } : undefined,
application_context: {
brand_name: BRAND,
user_action: "SUBSCRIBE_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createSubscription failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Create a one-time order (used for the Lifetime plan). */
export async function createOrder(params: {
amount: number
userId: string
plan: string
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v2/checkout/orders", {
method: "POST",
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "USD", value: params.amount.toFixed(2) },
custom_id: encodeCustomId(params.userId, params.plan),
description: `${BRAND} — Lifetime`,
},
],
application_context: {
brand_name: BRAND,
user_action: "PAY_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createOrder failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Capture an approved order. Returns the captured order (status COMPLETED). */
export async function captureOrder(orderId: string): Promise<{
status: string
custom_id?: string
} | null> {
const res = await paypalFetch(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
body: "{}",
})
if (!res.ok) return null
const json = (await res.json()) as {
status: string
purchase_units?: Array<{ custom_id?: string; payments?: { captures?: Array<{ custom_id?: string }> } }>
}
const unit = json.purchase_units?.[0]
const custom_id = unit?.custom_id ?? unit?.payments?.captures?.[0]?.custom_id
return { status: json.status, custom_id }
}
export type PaypalSubscription = {
id: string
status: string
custom_id?: string
billing_info?: { next_billing_time?: string }
}
export async function getSubscription(id: string): Promise<PaypalSubscription | null> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}`, { method: "GET" })
if (!res.ok) return null
return (await res.json()) as PaypalSubscription
}
export async function cancelSubscription(id: string, reason = "Cancelled by subscriber"): Promise<boolean> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}/cancel`, {
method: "POST",
body: JSON.stringify({ reason }),
})
// 204 = cancelled; 422 = already inactive (treat as success so the UI settles).
return res.ok || res.status === 204 || res.status === 422
}
-57
View File
@@ -1,57 +0,0 @@
// PayPal REST API client — OAuth2 client-credentials + a thin fetch helper.
//
// Enabled only when PAYPAL_CLIENT_ID and PAYPAL_SECRET are set (mirrors the
// gating used for the other optional integrations). PAYPAL_ENVIRONMENT selects
// the sandbox (default) or live host.
const ENVIRONMENT = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox"
const BASE_URL =
ENVIRONMENT === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
export function paypalConfigured(): boolean {
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET)
}
export function paypalEnvironment() {
return ENVIRONMENT
}
// Access tokens live ~9h; cache in-process (the app runs a persistent Node
// server, so this survives across requests) and refresh a minute early.
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) return cachedToken.token
const id = process.env.PAYPAL_CLIENT_ID
const secret = process.env.PAYPAL_SECRET
if (!id || !secret) throw new Error("PayPal is not configured")
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
})
if (!res.ok) throw new Error(`PayPal auth failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { access_token: string; expires_in: number }
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
return cachedToken.token
}
/** Authenticated fetch against the PayPal REST API. Path is relative (e.g. "/v1/..."). */
export async function paypalFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getAccessToken()
return fetch(`${BASE_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
})
}
-53
View File
@@ -1,53 +0,0 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import type { Plan } from "@/types"
// Applies PayPal subscription/order outcomes to a profile. Shared by the return
// handler (synchronous, on approval redirect) and the webhook (async, for
// renewals/cancellations). Both are idempotent.
const RECURRING: ReadonlyArray<Plan> = ["pro", "landlord"]
export async function fulfillSubscription(
userId: string,
plan: string,
subscriptionId: string,
nextBillingTime?: string | null,
status = "active",
): Promise<void> {
if (!RECURRING.includes(plan as Plan)) return
await db
.update(profiles)
.set({
plan: plan as Plan,
subscription_status: status,
paypal_subscription_id: subscriptionId,
billing_provider: "paypal",
plan_expires_at: nextBillingTime ?? null,
})
.where(eq(profiles.id, userId))
}
export async function fulfillLifetime(userId: string): Promise<void> {
await db
.update(profiles)
.set({ plan: "lifetime", subscription_status: "active", billing_provider: "paypal" })
.where(eq(profiles.id, userId))
}
/** Downgrade/mark a profile by its PayPal subscription id (cancel/expire/suspend). */
export async function markPaypalSubscriptionInactive(
subscriptionId: string,
status: string,
downgrade: boolean,
): Promise<void> {
await db
.update(profiles)
.set(
downgrade
? { subscription_status: status, plan: "starter", paypal_subscription_id: null, plan_expires_at: null }
: { subscription_status: status },
)
.where(eq(profiles.paypal_subscription_id, subscriptionId))
}
-28
View File
@@ -1,28 +0,0 @@
import type { Plan } from "@/types"
// PayPal billing-plan IDs, one per (plan, interval). Create them once with
// `node scripts/paypal-setup-plans.mjs` and paste the printed IDs into the
// environment. A plan/interval with no configured ID simply isn't offered.
const PAYPAL_PLAN_IDS: Record<string, string | undefined> = {
"pro:month": process.env.PAYPAL_PRO_MONTHLY_PLAN_ID,
"pro:year": process.env.PAYPAL_PRO_YEARLY_PLAN_ID,
"landlord:month": process.env.PAYPAL_LANDLORD_MONTHLY_PLAN_ID,
"landlord:year": process.env.PAYPAL_LANDLORD_YEARLY_PLAN_ID,
}
/** Recurring plans PayPal can bill (lifetime is a one-time order, not a plan). */
export const PAYPAL_RECURRING_PLANS = ["pro", "landlord"] as const
export function getPaypalPlanId(plan: Plan, interval: "month" | "year"): string | undefined {
return PAYPAL_PLAN_IDS[`${plan}:${interval}`] || undefined
}
/** True when at least one PayPal-billable plan is configured. */
export function anyPaypalPlanConfigured(): boolean {
return Object.values(PAYPAL_PLAN_IDS).some(Boolean)
}
/** Annual PayPal billing is offered only when both yearly plan IDs exist. */
export function paypalAnnualEnabled(): boolean {
return Boolean(PAYPAL_PLAN_IDS["pro:year"] && PAYPAL_PLAN_IDS["landlord:year"])
}
-36
View File
@@ -1,36 +0,0 @@
import { paypalFetch } from "./client"
// Verify an inbound PayPal webhook using PayPal's verify-webhook-signature API.
// Requires PAYPAL_WEBHOOK_ID (from the webhook you create in the PayPal app).
// Returns false (reject) when the id is missing or verification doesn't succeed.
export async function verifyPaypalWebhook(headers: Headers, rawBody: string): Promise<boolean> {
const webhookId = process.env.PAYPAL_WEBHOOK_ID
if (!webhookId) return false
let event: unknown
try {
event = JSON.parse(rawBody)
} catch {
return false
}
try {
const res = await paypalFetch("/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: headers.get("paypal-auth-algo"),
cert_url: headers.get("paypal-cert-url"),
transmission_id: headers.get("paypal-transmission-id"),
transmission_sig: headers.get("paypal-transmission-sig"),
transmission_time: headers.get("paypal-transmission-time"),
webhook_id: webhookId,
webhook_event: event,
}),
})
if (!res.ok) return false
const json = (await res.json()) as { verification_status?: string }
return json.verification_status === "SUCCESS"
} catch {
return false
}
}
+81 -1
View File
@@ -143,6 +143,53 @@ function sanitizeSegment(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
}
/**
* True iff a storage key lives in the given owner's namespace (`<ownerId>/…`).
* Keys are generated server-side as `<sanitized ownerId>/<scope>/<file>`, so any
* client-supplied key/path whose first segment differs belongs to another tenant
* (or is malformed) and must be rejected.
*/
export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean {
if (!key || !ownerId) return false
const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0]
return first === sanitizeSegment(ownerId)
}
/**
* Lightweight magic-byte check: reject a file whose real content doesn't match
* its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types
* without a reliable file signature (csv/txt) are allowed through. `head` should
* be the first ~16 bytes of the file.
*/
export function contentMatchesExtension(head: Buffer, ext: string): boolean {
const at = (offset: number, sig: number[]) =>
head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b)
switch (ext) {
case "pdf":
return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF
case "png":
return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
case "jpg":
case "jpeg":
return at(0, [0xff, 0xd8, 0xff])
case "gif":
return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8
case "webp":
return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP
case "docx":
case "xlsx":
return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK)
case "doc":
case "xls":
return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip
case "csv":
case "txt":
return true // no reliable signature
default:
return true
}
}
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
if (!body) return Buffer.alloc(0)
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
@@ -194,6 +241,36 @@ export async function saveFile(
return { key, size: file.size, type }
}
/**
* Persist raw bytes under `${userId}/${scope}/<random>.<ext>` (server-generated,
* so the key is always in the owner's namespace) and return the storage key.
* Used for server-side artifacts like signed e-sign PDFs.
*/
export async function saveBuffer(
buffer: Buffer,
opts: { userId: string; scope: string; ext: string }
): Promise<{ key: string }> {
const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}`
if (usingSpaces()) {
await s3().send(
new PutObjectCommand({
Bucket: SPACES_BUCKET,
Key: key,
Body: buffer,
ContentType: contentTypeForKey(key),
ACL: "private",
})
)
} else {
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, buffer)
}
return { key }
}
export async function readFile(key: string): Promise<Buffer> {
if (usingSpaces()) {
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
@@ -227,7 +304,10 @@ export async function presignGetUrl(
return toCdnUrl(signed)
}
export async function deleteFile(key: string): Promise<void> {
export async function deleteFile(key: string, ownerId: string): Promise<void> {
// Defense in depth: never delete an object outside the caller's own namespace,
// even if a stored storage_path was tampered with to point at another tenant.
if (!keyBelongsToOwner(key, ownerId)) return
try {
if (usingSpaces()) {
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
+14 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
// NOTE: The Content-Security-Policy is set per-request in `proxy.ts` (Next
// middleware) so `script-src` can carry a per-request nonce instead of
@@ -31,4 +32,16 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
org: "phluit",
project: "property-management-network",
// Only print source-map upload logs in CI.
silent: !process.env.CI,
// Upload a wider set of client source maps for readable stack traces.
widenClientFileUpload: true,
// Tree-shake Sentry's debug logger to shrink the client bundle.
disableLogger: true,
// Source-map upload runs at build time only when SENTRY_AUTH_TOKEN is set;
// without it the build still succeeds (stack traces just aren't un-minified).
});
+1855 -74
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.110.0",
"@aws-sdk/client-s3": "^3.1077.0",
"@aws-sdk/s3-request-presigner": "^3.1078.0",
"@fullcalendar/daygrid": "^6.1.21",
@@ -22,6 +23,7 @@
"@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21",
"@radix-ui/react-switch": "^1.2.6",
"@sentry/nextjs": "^10.63.0",
"@types/papaparse": "^5.5.2",
"better-auth": "^1.6.20",
"clsx": "^2.1.1",
+71 -3
View File
@@ -28,6 +28,19 @@ const PROTECTED_PATHS = [
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
// Origin of the Sentry ingest endpoint, derived from the public DSN so the
// CSP stays in sync with whatever project/region the DSN points at. Returns
// null when Sentry is not configured.
function sentryIngestOrigin(): string | null {
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
if (!dsn) return null
try {
return new URL(dsn).origin
} catch {
return null
}
}
// Build the per-request Content-Security-Policy. `script-src` carries a
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline
@@ -36,6 +49,7 @@ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
// scripts automatically.
function buildCsp(nonce: string): string {
const isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin()
// In development, Next.js/React and Turbopack HMR require eval() for hot
// reloading and debugging features, and open a dev websocket. These are NOT
@@ -43,9 +57,14 @@ function buildCsp(nonce: string): string {
const scriptSrc = isDev
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com`
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`
const connectSrc = isDev
? "connect-src 'self' ws: wss: https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
: "connect-src 'self' https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
const connectSrc = [
"connect-src 'self'",
isDev ? "ws: wss:" : "",
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
sentry ?? "",
]
.filter(Boolean)
.join(" ")
return [
"default-src 'self'",
@@ -54,13 +73,39 @@ function buildCsp(nonce: string): string {
scriptSrc,
"font-src 'self' data:",
connectSrc,
// Sentry Session Replay spins up its compression worker from a blob: URL;
// without worker-src the browser falls back to script-src and blocks it.
"worker-src 'self' blob:",
"frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join("; ")
}
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
// an auth page we confirm the session is actually alive — otherwise a stale
// cookie loops forever: /dashboard → /login (server sees no session) →
// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS.
// "unknown" (auth service unreachable / rate-limited) renders the auth page
// without touching cookies, which is safe in both directions.
async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> {
try {
const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin
const res = await fetch(new URL("/api/auth/get-session", base), {
headers: { cookie: request.headers.get("cookie") ?? "" },
cache: "no-store",
})
if (!res.ok) return "unknown"
// Better Auth returns JSON `null` when the session is missing or revoked.
const session = await res.json()
return session ? "valid" : "invalid"
} catch {
return "unknown"
}
}
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
@@ -76,11 +121,19 @@ export async function proxy(request: NextRequest) {
}
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
let dropStaleSessionCookie = false
if (isAuthPage && sessionCookie) {
const state = await sessionState(request)
if (state === "valid") {
const url = request.nextUrl.clone()
url.pathname = "/dashboard"
return NextResponse.redirect(url)
}
// Dead cookie (session revoked or expired): render the auth page and drop
// the cookie below so protected paths stop treating this visitor as
// signed in. On "unknown", render the page but keep the cookie.
dropStaleSessionCookie = state === "invalid"
}
// Per-request CSP nonce. UUID contains only hex + dashes, so it never
// includes HTML-escape characters (which Next rejects in nonces).
@@ -96,6 +149,21 @@ export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Also set the CSP on the outgoing response so the browser enforces it.
response.headers.set("Content-Security-Policy", csp)
if (dropStaleSessionCookie) {
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
for (const cookie of request.cookies.getAll()) {
if (!cookie.name.includes("better-auth.session_token")) continue
response.cookies.set(cookie.name, "", {
maxAge: 0,
path: "/",
httpOnly: true,
sameSite: "lax",
secure: cookie.name.startsWith("__Secure-"),
})
}
}
return response
}
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

-102
View File
@@ -1,102 +0,0 @@
// One-time PayPal setup: creates the product + the recurring billing plans and
// prints the plan IDs to paste into your environment.
//
// 1. Set PAYPAL_CLIENT_ID / PAYPAL_SECRET (and PAYPAL_ENVIRONMENT) in .env.local
// 2. node scripts/paypal-setup-plans.mjs
// 3. Copy the printed PAYPAL_*_PLAN_ID lines into .env.local / production env
//
// Amounts mirror the app's pricing (Pro $29/mo, Landlord $59/mo); yearly is
// billed at 10× monthly (~2 months free). Adjust in the PayPal dashboard if you
// want different annual pricing. Safe to re-run (it creates fresh plans).
import { config } from "dotenv"
config({ path: ".env.local", quiet: true })
const ENV = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox"
const BASE = ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
const id = process.env.PAYPAL_CLIENT_ID
const secret = process.env.PAYPAL_SECRET
if (!id || !secret) {
console.error("[paypal-setup] Set PAYPAL_CLIENT_ID and PAYPAL_SECRET in .env.local first.")
process.exit(1)
}
async function getToken() {
const r = await fetch(`${BASE}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
})
if (!r.ok) throw new Error(`auth ${r.status}: ${await r.text()}`)
return (await r.json()).access_token
}
const token = await getToken()
const post = (path, body) =>
fetch(`${BASE}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
})
console.log(`[paypal-setup] Environment: ${ENV}`)
const prodRes = await post("/v1/catalogs/products", {
name: "Property Management Network",
description: "Property Management Network subscription",
type: "SERVICE",
category: "SOFTWARE",
})
if (!prodRes.ok) {
console.error("[paypal-setup] product creation failed:", await prodRes.text())
process.exit(1)
}
const product = await prodRes.json()
console.log(`[paypal-setup] Product: ${product.id}`)
const AMOUNTS = { pro: 29, landlord: 59 }
const envLines = []
for (const plan of ["pro", "landlord"]) {
for (const interval of ["month", "year"]) {
const amount = interval === "month" ? AMOUNTS[plan] : AMOUNTS[plan] * 10
const res = await post("/v1/billing/plans", {
product_id: product.id,
name: `${plan[0].toUpperCase()}${plan.slice(1)} ${interval === "month" ? "Monthly" : "Yearly"}`,
status: "ACTIVE",
billing_cycles: [
{
frequency: { interval_unit: interval === "month" ? "MONTH" : "YEAR", interval_count: 1 },
tenure_type: "REGULAR",
sequence: 1,
total_cycles: 0,
pricing_scheme: { fixed_price: { value: amount.toFixed(2), currency_code: "USD" } },
},
],
payment_preferences: {
auto_bill_outstanding: true,
setup_fee_failure_action: "CONTINUE",
payment_failure_threshold: 2,
},
})
if (!res.ok) {
console.error(`[paypal-setup] plan ${plan}/${interval} failed:`, await res.text())
continue
}
const p = await res.json()
const key = `PAYPAL_${plan.toUpperCase()}_${interval === "month" ? "MONTHLY" : "YEARLY"}_PLAN_ID`
console.log(` ${plan}/${interval} $${amount}${p.id}`)
envLines.push(`${key}=${p.id}`)
}
}
console.log("\n[paypal-setup] Add these to your environment:\n")
console.log(envLines.join("\n"))
+13
View File
@@ -0,0 +1,13 @@
// Sentry initialization for the Edge runtime (middleware / proxy.ts).
// Loaded by instrumentation.ts when NEXT_RUNTIME === "edge".
import * as Sentry from "@sentry/nextjs"
const dsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
Sentry.init({
dsn,
enabled: !!dsn,
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0,
sendDefaultPii: false,
})
+16
View File
@@ -0,0 +1,16 @@
// Sentry initialization for the Node.js server runtime.
// Loaded by instrumentation.ts when NEXT_RUNTIME === "nodejs".
import * as Sentry from "@sentry/nextjs"
const dsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
Sentry.init({
dsn,
// Inert until a DSN is configured — safe to ship before Sentry is set up.
enabled: !!dsn,
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
// Performance tracing — sample less in production to control volume/cost.
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0,
// Don't attach PII (IP, cookies, request bodies) by default.
sendDefaultPii: false,
})