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 # 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 # Update: doctl apps update <APP_ID> --spec .do/app.yaml
# #
# SOURCE: image-based from DigitalOcean Container Registry (DOCR). The app's git # SOURCE: App Platform builds the Dockerfile directly from GitHub
# lives on self-hosted Gitea, which App Platform cannot pull, so we build the # (github.com/silkoserfo/property-management-network). Pushes to `main`
# Docker image ourselves and push it to DOCR. See DIGITALOCEAN.md for the full # auto-redeploy (deploy_on_push). No DOCR image build/push needed.
# build/push/deploy walkthrough.
# #
# SECRETS: values marked `type: SECRET` are placeholders — set the real values in # SECRETS: values marked `type: SECRET` are placeholders — set the real values in
# the App Platform dashboard (App → Settings → Environment Variables) or via # the App Platform dashboard (App → Settings → Environment Variables) or via the
# `doctl`. Never commit real secrets to this file. # create spec. Never commit real secrets to this file.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
name: property-management-network name: property-management-network
region: nyc region: nyc
services: services:
- name: web - name: web
# Pre-built image pushed to DOCR (repository must exist in your registry). # Built by App Platform from GitHub using the repo Dockerfile.
image: github:
registry_type: DOCR repo: silkoserfo/property-management-network
repository: property-management-network branch: main
tag: latest deploy_on_push: true
deploy_on_push: dockerfile_path: Dockerfile
enabled: true
instance_count: 1 instance_count: 1
instance_size_slug: apps-s-1vcpu-1gb instance_size_slug: apps-s-1vcpu-1gb
http_port: 3000 http_port: 3000
@@ -37,32 +35,35 @@ services:
failure_threshold: 3 failure_threshold: 3
envs: envs:
# ── App URLs ────────────────────────────────────────────────────────── # ── App URLs ──────────────────────────────────────────────────────────
# ${APP_URL} resolves to the app's public URL at runtime. NOTE: the client # NEXT_PUBLIC_* are inlined into the client bundle at BUILD time, so they
# bundle bakes NEXT_PUBLIC_APP_URL at *image build* time (see Dockerfile / # must be RUN_AND_BUILD_TIME with the literal domain we serve on.
# DIGITALOCEAN.md), so build the image with the same URL you serve on.
- key: NEXT_PUBLIC_APP_URL - key: NEXT_PUBLIC_APP_URL
scope: RUN_TIME scope: RUN_AND_BUILD_TIME
value: ${APP_URL} value: https://propertymanagement.network
- key: BETTER_AUTH_URL - key: BETTER_AUTH_URL
scope: RUN_TIME scope: RUN_TIME
value: ${APP_URL} value: https://propertymanagement.network
- key: NEXT_PUBLIC_APP_NAME - key: NEXT_PUBLIC_APP_NAME
scope: RUN_TIME scope: RUN_AND_BUILD_TIME
value: Property Management Network 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 - key: DATABASE_URL
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# Verified TLS (encrypted + certificate-checked). DO Managed Postgres uses # Verified TLS: DO's Managed Postgres CA isn't in the system trust store, so
# a CA that isn't in the system trust store, so paste the cluster's CA cert # paste the cluster CA PEM (repo root ca-certificate.crt) into DATABASE_CA.
# into DATABASE_CA: DO control panel → Database → Connection Details → # With `require` + a valid CA the app connects verified; without a valid CA
# "Download CA certificate", then paste its PEM contents as the DATABASE_CA # it fails loud rather than run unverified.
# 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.
- key: DATABASE_SSL - key: DATABASE_SSL
scope: RUN_TIME scope: RUN_TIME
value: require value: require
@@ -70,8 +71,7 @@ services:
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# Schema is migrated out-of-band (as doadmin), NOT on boot — the app user # Schema is migrated out-of-band (as doadmin), NOT on boot.
# intentionally lacks DDL rights. Keep this false; run migrations manually.
- key: RUN_MIGRATIONS_ON_START - key: RUN_MIGRATIONS_ON_START
scope: RUN_TIME scope: RUN_TIME
value: "false" value: "false"
@@ -81,14 +81,15 @@ services:
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# Google OAuth (optional — leave blank to disable the Google button).
- key: GOOGLE_CLIENT_ID - key: GOOGLE_CLIENT_ID
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: ""
- key: GOOGLE_CLIENT_SECRET - key: GOOGLE_CLIENT_SECRET
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: ""
# ── Stripe ──────────────────────────────────────────────────────────── # ── Stripe ────────────────────────────────────────────────────────────
- key: STRIPE_SECRET_KEY - key: STRIPE_SECRET_KEY
@@ -99,22 +100,21 @@ services:
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD 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 ──────────────────────────────────────────────────────────── # ── AI provider (Anthropic default; OpenAI optional) ──────────────────
- key: OPENAI_API_KEY - key: ANTHROPIC_API_KEY
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD 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) ──────────────────────────────────────────── # ── 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 - key: SMTP_HOST
scope: RUN_TIME scope: RUN_TIME
value: mail.smtp2go.com value: mail.smtp2go.com
@@ -133,10 +133,9 @@ services:
scope: RUN_TIME scope: RUN_TIME
value: postmaster@propertymanagement.network value: postmaster@propertymanagement.network
# ── Cloudflare Turnstile (site key is public; baked into the client bundle # ── Cloudflare Turnstile (site key public; baked at build time) ──
# at image build time — keep it in sync when you build) ──
- key: NEXT_PUBLIC_TURNSTILE_SITE_KEY - key: NEXT_PUBLIC_TURNSTILE_SITE_KEY
scope: RUN_TIME scope: RUN_AND_BUILD_TIME
value: 0x4AAAAAADuDQverznfv1a60 value: 0x4AAAAAADuDQverznfv1a60
- key: TURNSTILE_SECRET_KEY - key: TURNSTILE_SECRET_KEY
scope: RUN_TIME scope: RUN_TIME
@@ -165,8 +164,53 @@ services:
scope: RUN_TIME scope: RUN_TIME
value: https://nyc3.cdn.digitaloceanspaces.com 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/*) ── # ── Cron (Bearer token the DO Function sends to /api/cron/*) ──
- key: CRON_SECRET - key: CRON_SECRET
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD 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 # 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 # pure key swap. Optionally pre-create the catalog: node scripts/stripe-setup.mjs
# === PAYPAL (optional — alternative subscription checkout) === # === AI PROVIDER (OpenAI and/or Anthropic) ===
# Lets landlords pay for their plan with PayPal alongside Stripe. Leave blank to # The active provider is chosen by an admin in Settings → System. Configure the
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com; # key(s) for whichever provider(s) you want available; the app falls back to the
# keep PAYPAL_ENVIRONMENT=sandbox for testing. Create a webhook pointing to # configured one if the selected provider's key is missing.
# <APP_URL>/api/paypal/webhook and set its id as PAYPAL_WEBHOOK_ID. Generate the # OpenAI — https://platform.openai.com/api-keys
# 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
OPENAI_API_KEY=sk-your-api-key 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) === # === EMAIL (SMTP — e.g. SMTP2GO) ===
# Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS. # Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS.
@@ -87,16 +79,18 @@ QBO_ENVIRONMENT=sandbox
XERO_CLIENT_ID= XERO_CLIENT_ID=
XERO_CLIENT_SECRET= XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) === # === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# Dropbox Sign: API-key auth. Set DROPBOX_SIGN_TEST_MODE=true while testing. # DocuSign: register ONE DocuSign app (integration key) here; each landlord then
DROPBOX_SIGN_API_KEY= # connects their own DocuSign account via OAuth from Settings → Integrations.
DROPBOX_SIGN_TEST_MODE=true # Redirect URI to register in the DocuSign app: <APP_URL>/api/esign/docusign/callback
# DocuSign: uses a pre-obtained access token (JWT/OAuth). Webhook: DocuSign # DOCUSIGN_OAUTH_BASE: account-d.docusign.com (demo) or account.docusign.com (prod).
# Connect → <APP_URL>/api/esign/docusign/webhook ; Dropbox Sign callback → DOCUSIGN_CLIENT_ID=
# <APP_URL>/api/esign/dropbox_sign/webhook DOCUSIGN_CLIENT_SECRET=
DOCUSIGN_ACCESS_TOKEN= DOCUSIGN_OAUTH_BASE=account-d.docusign.com
DOCUSIGN_ACCOUNT_ID= # Dropbox Sign: no server credentials — landlords paste their own API key in the
DOCUSIGN_BASE_URI=https://demo.docusign.net # 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 === # === APP ===
NEXT_PUBLIC_APP_URL=http://localhost:3000 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_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 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) === # === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Property addresses are geocoded on save via OpenStreetMap Nominatim and shown # Property addresses are geocoded on save via OpenStreetMap Nominatim and shown
# on a Leaflet map (both keyless & free). Nominatim's policy requires an # 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 # 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 # 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) === # === AI (OpenAI) ===
OPENAI_API_KEY=sk-xxx OPENAI_API_KEY=sk-xxx
@@ -103,17 +88,17 @@ QBO_ENVIRONMENT=production
XERO_CLIENT_ID= XERO_CLIENT_ID=
XERO_CLIENT_SECRET= XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) === # === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# Leave blank to hide/disable a provider on the lease page. Configure the # DocuSign: register ONE DocuSign app; landlords connect their own account via
# provider callbacks to point at this app: # OAuth from Settings → Integrations. Register this redirect URI in the app:
# Dropbox Sign callback → <APP_URL>/api/esign/dropbox_sign/webhook # <APP_URL>/api/esign/docusign/callback
# DocuSign Connect → <APP_URL>/api/esign/docusign/webhook # Use account.docusign.com in production (account-d.docusign.com for demo).
# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs. DOCUSIGN_CLIENT_ID=
DROPBOX_SIGN_API_KEY= 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 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) === # === APP (NEXT_PUBLIC_* — also set as Build Variables) ===
NEXT_PUBLIC_APP_URL=https://propertymanagement.network 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_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 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) === # === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM # Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM
# tiles. No API key or billing. Nominatim REQUIRES an identifying User-Agent — # 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* yarn-error.log*
.pnpm-debug.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 files (can opt-in for committing if needed)
.env* .env*
!.env.example !.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_URL=https://<your-domain> \
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \ --build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \ --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 . -t $REG/property-management-network:latest .
docker push $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 > 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`. > `*.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`, `.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`, `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`, `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 (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 `SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves
for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime. 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_URL
ARG NEXT_PUBLIC_APP_NAME="Property Management Network" ARG NEXT_PUBLIC_APP_NAME="Property Management Network"
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY 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_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY 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 --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build 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** **Core operations**
- 🏢 **Properties & units** — manage your whole portfolio with live occupancy tracking and a map view (addresses are auto-geocoded). - 🏢 **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). - 👥 **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. - 🔧 **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). - 📄 **Leases** — expiry countdowns, automated 60/30/7-day email alerts, and **e-signature** (DocuSign / Dropbox Sign).
- 🧾 **Expenses** — categorized logging with recurring-expense support. - 🧾 **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`. | | 🌐 **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. | | 🪝 **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). | | 📚 **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**. | | ✍️ **E-signature** | Send leases for signature via **DocuSign** or **Dropbox Sign**. |
| 🔑 **Auth** | Email/password and Google OAuth (Better Auth). | | 🔑 **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) | | 🏆 **Landlord** | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) |
| ♾️ **Lifetime** | $199 once | Everything in Landlord, forever | | ♾️ **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** | | Database | PostgreSQL via **Drizzle ORM** |
| Auth | Better Auth (email/password + Google OAuth) | | Auth | Better Auth (email/password + Google OAuth) |
| Object storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) | | Object storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) |
| Payments | Stripe + PayPal | | Payments | Stripe |
| AI | OpenAI (`gpt-4o-mini`) | | AI | OpenAI (`gpt-4o-mini`) |
| Email | SMTP (SMTP2GO) | | Email | SMTP (SMTP2GO) |
| Maps | Leaflet + OpenStreetMap / Nominatim geocoding | | Maps | Leaflet + OpenStreetMap / Nominatim geocoding |
@@ -113,7 +113,7 @@ Copy the template and fill in your own values:
cp .env.example .env.local 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 ### 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`. - **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. - **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 ### 5. ▶️ Run locally
@@ -158,7 +158,7 @@ app/
├── api/ ├── api/
│ ├── v1/ # 🌐 Public REST API (Bearer API keys) │ ├── v1/ # 🌐 Public REST API (Bearer API keys)
│ ├── webhooks + cron/ # 🪝 Outbound webhook delivery + scheduled jobs │ ├── webhooks + cron/ # 🪝 Outbound webhook delivery + scheduled jobs
│ ├── stripe/ paypal/ # 💳 Billing + payment links + provider webhooks │ ├── stripe/ # 💳 Billing + payment links + provider webhooks
│ ├── integrations/ # 📚 QuickBooks / Xero OAuth │ ├── integrations/ # 📚 QuickBooks / Xero OAuth
│ ├── esign/ # ✍️ DocuSign / Dropbox Sign │ ├── esign/ # ✍️ DocuSign / Dropbox Sign
│ └── … # Properties, tenants, rent, maintenance, documents, AI │ └── … # Properties, tenants, rent, maintenance, documents, AI
@@ -169,7 +169,7 @@ lib/
├── auth.ts account.ts # Better Auth + team/account scoping ├── auth.ts account.ts # Better Auth + team/account scoping
├── storage.ts # Object storage (Spaces) with local-disk dev fallback ├── storage.ts # Object storage (Spaces) with local-disk dev fallback
├── webhooks/ # Event catalog, HMAC signing, SSRF guard, delivery ├── webhooks/ # Event catalog, HMAC signing, SSRF guard, delivery
├── stripe/ paypal/ # Billing clients & plans ├── stripe/ # Billing clients & plans
├── accounting/ esign/ # QuickBooks/Xero & DocuSign/Dropbox Sign ├── accounting/ esign/ # QuickBooks/Xero & DocuSign/Dropbox Sign
├── ai/ # OpenAI client + prompts ├── ai/ # OpenAI client + prompts
├── email/ # SMTP (SMTP2GO) client + HTML templates ├── email/ # SMTP (SMTP2GO) client + HTML templates
+16 -1
View File
@@ -1,6 +1,8 @@
import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries" import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries"
import { getMaintenanceMode } from "@/lib/settings" import { getMaintenanceMode } from "@/lib/settings"
import { aiProviderStatus } from "@/lib/ai/provider"
import { MaintenanceToggle } from "@/components/admin/maintenance-toggle" import { MaintenanceToggle } from "@/components/admin/maintenance-toggle"
import { AiProviderToggle } from "@/components/admin/ai-provider-toggle"
import { formatDate } from "@/lib/utils" import { formatDate } from "@/lib/utils"
import { Settings, Database, Table2 } from "lucide-react" import { Settings, Database, Table2 } from "lucide-react"
@@ -13,10 +15,11 @@ function humanize(name: string): string {
} }
export default async function AdminSystemPage() { export default async function AdminSystemPage() {
const [{ counts, cronLastRun }, env, maintenance] = await Promise.all([ const [{ counts, cronLastRun }, env, maintenance, aiProvider] = await Promise.all([
getSystemCounts(), getSystemCounts(),
Promise.resolve(getEnvHealth()), Promise.resolve(getEnvHealth()),
getMaintenanceMode(), getMaintenanceMode(),
aiProviderStatus(),
]) ])
return ( return (
@@ -37,6 +40,18 @@ export default async function AdminSystemPage() {
/> />
</div> </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"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
{/* ── Environment configuration ───────────────────────────────── */} {/* ── Environment configuration ───────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden"> <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 Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { resetPassword } from "@/app/actions/auth" import { resetPassword } from "@/app/actions/auth"
export const metadata: Metadata = {
title: "Reset password",
robots: { index: false, follow: true },
}
export default async function ForgotPasswordPage({ export default async function ForgotPasswordPage({
searchParams, searchParams,
}: { }: {
+6
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signIn, signInWithGoogle } from "@/app/actions/auth" import { signIn, signInWithGoogle } from "@/app/actions/auth"
export const metadata: Metadata = {
title: "Sign in",
robots: { index: false, follow: true },
}
export default async function LoginPage({ export default async function LoginPage({
searchParams, searchParams,
}: { }: {
+13 -19
View File
@@ -4,13 +4,16 @@ import { db } from "@/lib/db"
import { leases as leasesTable } from "@/lib/db/schema" import { leases as leasesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { listAdapters, listRequestsForLease } from "@/lib/esign" import { listEsignConnections, listRequestsForLease } from "@/lib/esign"
import Link from "next/link" import Link from "next/link"
import { FileText, ExternalLink } from "lucide-react" import { FileText } from "lucide-react"
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils" import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { LeaseActions } from "@/components/forms/lease-actions" import { LeaseActions } from "@/components/forms/lease-actions"
import { EsignLease } from "@/components/forms/esign-lease" 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" } export const metadata = { title: "Lease" }
@@ -56,8 +59,12 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
if (!lease) notFound() if (!lease) notFound()
const esignRequests = await listRequestsForLease(ownerId, leaseId) const esignRequests = await listRequestsForLease(ownerId, leaseId)
const esignProviders = listAdapters() const esignConnections = await listEsignConnections(ownerId)
const canSendEsign = ctx.canWrite && !!lease.document_url && !!lease.tenant?.email 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 const esignDisabledReason = !ctx.canWrite
? "You have read-only access." ? "You have read-only access."
: !lease.document_url : !lease.document_url
@@ -196,24 +203,11 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
</div> </div>
</div> </div>
{lease.document_url && ( <LeaseDocument leaseId={leaseId} documentUrl={lease.document_url} canWrite={ctx.canWrite} />
<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>
)}
<EsignLease <EsignLease
leaseId={leaseId} leaseId={leaseId}
providers={esignProviders} connected={connectedProviders}
requests={esignRequests} requests={esignRequests}
canSend={canSendEsign} canSend={canSendEsign}
disabledReason={esignDisabledReason} disabledReason={esignDisabledReason}
+3 -19
View File
@@ -5,9 +5,7 @@ import { profiles, properties, tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { CheckoutButton } from "@/components/forms/checkout-button" import { CheckoutButton } from "@/components/forms/checkout-button"
import { PortalButton } from "@/components/forms/portal-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 { getPlanLabel, PLAN_LIMITS, annualEnabled } from "@/lib/stripe/plans"
import { paypalConfigured } from "@/lib/paypal/client"
import { Check } from "lucide-react" import { Check } from "lucide-react"
import type { Plan } from "@/types" import type { Plan } from "@/types"
@@ -59,7 +57,7 @@ const PLANS = [
export default async function BillingPage({ export default async function BillingPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ success?: string; canceled?: string; error?: string }> searchParams: Promise<{ success?: string; canceled?: string }>
}) { }) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) redirect("/login") if (!user) redirect("/login")
@@ -72,16 +70,12 @@ export default async function BillingPage({
plan_expires_at: true, plan_expires_at: true,
stripe_customer_id: true, stripe_customer_id: true,
stripe_subscription_id: true, stripe_subscription_id: true,
paypal_subscription_id: true,
billing_provider: true,
}, },
}) })
const params = await searchParams const params = await searchParams
const currentPlan = (profile?.plan ?? "starter") as Plan const currentPlan = (profile?.plan ?? "starter") as Plan
const hasStripeAccount = !!profile?.stripe_customer_id 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 limits = PLAN_LIMITS[currentPlan]
const canBillAnnually = annualEnabled() const canBillAnnually = annualEnabled()
@@ -113,12 +107,6 @@ export default async function BillingPage({
Checkout canceled no charge was made. Checkout canceled no charge was made.
</div> </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 */} {/* Current plan */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5"> <div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between"> <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> <p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
)} )}
</div> </div>
{currentPlan !== "starter" && currentPlan !== "lifetime" && {hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
(isPaypal ? (
<PaypalCancelButton />
) : hasStripeAccount ? (
<PortalButton /> <PortalButton />
) : null)} )}
</div> </div>
</div> </div>
@@ -197,7 +182,6 @@ export default async function BillingPage({
label={plan.cta} label={plan.cta}
highlight={plan.highlight} highlight={plan.highlight}
annualAvailable={canBillAnnually && plan.key !== "lifetime"} annualAvailable={canBillAnnually && plan.key !== "lifetime"}
paypalEnabled={paypalEnabled}
/> />
)} )}
</div> </div>
+29 -2
View File
@@ -2,7 +2,9 @@ import { redirect } from "next/navigation"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { listProviders, listConnections } from "@/lib/accounting" import { listProviders, listConnections } from "@/lib/accounting"
import { listEsignAdapters, listEsignConnections } from "@/lib/esign"
import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations" import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations"
import { EsignIntegrations } from "@/components/dashboard/esign-integrations"
export const metadata = { title: "Integrations" } export const metadata = { title: "Integrations" }
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -20,10 +22,34 @@ export default async function IntegrationsPage({
const providers = listProviders() const providers = listProviders()
const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : [] 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 ( 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> <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"> <p className="text-sm text-white/40 mt-0.5">
Connect your accounting software to automatically push rent income and expenses into your books. Connect your accounting software to automatically push rent income and expenses into your books.
</p> </p>
@@ -35,5 +61,6 @@ export default async function IntegrationsPage({
flash={{ connected: sp.connected, error: sp.error }} flash={{ connected: sp.connected, error: sp.error }}
/> />
</div> </div>
</div>
) )
} }
+20
View File
@@ -8,6 +8,7 @@ import { z } from "zod"
import { getAdminSession } from "@/lib/session" import { getAdminSession } from "@/lib/session"
import { logAdminAction } from "@/lib/admin/audit" import { logAdminAction } from "@/lib/admin/audit"
import { setMaintenanceMode } from "@/lib/settings" import { setMaintenanceMode } from "@/lib/settings"
import { setAiProvider, type AiProvider } from "@/lib/ai/provider"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { profiles, user as userTable } from "@/lib/db/schema" import { profiles, user as userTable } from "@/lib/db/schema"
@@ -158,3 +159,22 @@ export async function setSiteMaintenance(enabled: boolean, message?: string) {
revalidatePath("/", "layout") revalidatePath("/", "layout")
return { ok: true } 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, headers: h,
}) })
} catch (e) { } 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)}`) redirect(`/signup?error=${encodeURIComponent(msg)}`)
} }
+63 -1
View File
@@ -1,9 +1,27 @@
"use server" "use server"
import { revalidatePath } from "next/cache" 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 { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" 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) { export async function sendLeaseForSignatureAction(leaseId: string, provider: string) {
const user = await getSessionUser() const user = await getSessionUser()
@@ -15,3 +33,47 @@ export async function sendLeaseForSignatureAction(leaseId: string, provider: str
revalidatePath(`/leases/${leaseId}`) revalidatePath(`/leases/${leaseId}`)
return { ok: true } 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" } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account" 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 { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts" import { dataBlock } from "@/lib/ai/prompts"
@@ -21,6 +22,9 @@ export async function POST(request: Request) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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") const quota = await enforceAiQuota(user.id, "ai_ask")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) 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. 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({ let answer: string
model: "gpt-4o-mini", try {
max_tokens: 1024, answer = await aiComplete({
messages: [ messages: [
{ role: "system", content: context }, { role: "system", content: context },
{ role: "user", content: question }, { role: "user", content: question },
], ],
maxTokens: 1024,
}) })
} catch (err) {
const answer = completion.choices[0].message.content ?? "" 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 } }) 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 { properties, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account" 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 { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
@@ -12,6 +13,9 @@ export async function POST(request: Request) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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") const quota = await enforceAiQuota(user.id, "ai_maintenance_summary")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) 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 }, columns: { name: true },
}) })
const completion = await openai.chat.completions.create({ const text = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 1024,
messages: [ messages: [
{ role: "system", content: MAINTENANCE_SUMMARY_PROMPT }, { 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))}`, 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 let summary
try { try {
summary = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim()) summary = JSON.parse(text)
} catch { } catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
} }
+9 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" 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 { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts" import { dataBlock } from "@/lib/ai/prompts"
@@ -38,6 +39,9 @@ export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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") const quota = await enforceAiQuota(user.id, "ai_predictions")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) 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.` Only return valid JSON, no other text.`
const completion = await openai.chat.completions.create({ const content = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 2000,
messages: [{ role: "user", content: prompt }], messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" }, maxTokens: 2000,
json: true,
}) })
let predictions: any[] = [] let predictions: any[] = []
try { try {
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}") const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? []) predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
} catch { } catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
+9 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" 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 { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts" import { dataBlock } from "@/lib/ai/prompts"
@@ -37,6 +38,9 @@ export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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") const quota = await enforceAiQuota(user.id, "ai_recommendations")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) 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[] = [] let recommendations: any[] = []
try { try {
const completion = await openai.chat.completions.create({ const content = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 1500,
messages: [{ role: "user", content: prompt }], 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 ?? []) recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
} catch (err: any) { } catch (err: any) {
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 }) 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 { NextResponse } from "next/server"
import { z } from "zod" import { z } from "zod"
import { getSessionUser } from "@/lib/session" 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 { RENT_RECEIPT_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
@@ -25,6 +26,9 @@ export async function POST(request: Request) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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") const quota = await enforceAiQuota(user.id, "ai_rent_receipt")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) 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. // Pass only the whitelisted, validated fields to the model.
const { payment_id, ...receiptFields } = parsed.data const { payment_id, ...receiptFields } = parsed.data
const completion = await openai.chat.completions.create({ const text = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 1024,
messages: [ messages: [
{ role: "system", content: RENT_RECEIPT_PROMPT }, { 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)), content: dataBlock("PAYMENT DETAILS", JSON.stringify(receiptFields, null, 2)),
}, },
], ],
maxTokens: 1024,
json: true,
}) })
const text = completion.choices[0].message.content ?? ""
let receipt let receipt
try { try {
receipt = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim()) receipt = JSON.parse(text)
} catch { } catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) 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))) await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
if (doc.storage_path) { if (doc.storage_path) {
await deleteFile(doc.storage_path) await deleteFile(doc.storage_path, ownerId)
} }
return NextResponse.json({ success: true }) 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 { db } from "@/lib/db"
import { documents, properties } from "@/lib/db/schema" import { documents, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" 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 { checkStorageLimit } from "@/lib/plan-limits"
import { ownsProperty, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" 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) 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 (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 }) 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) const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 }) 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 }) 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. // Whitelist insertable columns — never trust client-supplied user_id/id/created_at.
const [data] = await db const [data] = await db
.insert(documents) .insert(documents)
@@ -122,8 +147,8 @@ export async function POST(request: Request) {
tenant_id: tenantId, tenant_id: tenantId,
name: body.name as string, name: body.name as string,
category: (body.category as typeof documents.$inferInsert.category) ?? "other", category: (body.category as typeof documents.$inferInsert.category) ?? "other",
file_url: body.file_url as string, file_url: fileUrl,
storage_path: body.storage_path as string | undefined, storage_path: storagePath,
file_type: body.file_type as string | undefined, file_type: body.file_type as string | undefined,
file_size: body.file_size as number | 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 { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { runFollowUpsForUser } from "@/lib/follow-ups" import { runFollowUpsForUser } from "@/lib/follow-ups"
export async function POST() { export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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 // Preserve the original response shape ({ sent, results }). The detailed
// per-follow-up rows now live only in follow_up_log; the client re-fetches // 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 { 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 { 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. // OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider: pid } = await params const { provider: pid } = await params
const prov = getProvider(pid) const prov = getProvider(pid)
const url = new URL(request.url)
const settings = new URL("/settings/integrations", 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 code = url.searchParams.get("code")
const state = url.searchParams.get("state") const state = url.searchParams.get("state")
const realmId = url.searchParams.get("realmId") // QuickBooks includes this const realmId = url.searchParams.get("realmId") // QuickBooks includes this
const oauthError = url.searchParams.get("error") const oauthError = url.searchParams.get("error")
if (oauthError || !prov) { if (oauthError || !prov) return done({ error: "connect_failed" })
settings.searchParams.set("error", "connect_failed")
return NextResponse.redirect(settings)
}
const st = state ? verifyState(state) : null const st = state ? verifyState(state) : null
if (!code || !st || st.provider !== pid) { // CSRF: the state's nonce must match the cookie set at connect time, and the
settings.searchParams.set("error", "invalid_state") // current session must be the same owner that initiated the connect.
return NextResponse.redirect(settings) 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 { try {
const tokens = await prov.exchangeCode(code, realmId) const tokens = await prov.exchangeCode(code, realmId)
if (!tokens.realmId) throw new Error("No organisation returned from provider") if (!tokens.realmId) throw new Error("No organisation returned from provider")
await saveConnection(st.ownerId, pid as Provider, tokens) await saveConnection(st.ownerId, pid as Provider, tokens)
settings.searchParams.set("connected", pid) return done({ connected: pid })
} catch { } 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 { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { getProvider } from "@/lib/accounting" 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). // Starts the OAuth connect flow for an accounting provider (owner-only).
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { 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) return NextResponse.redirect(settings)
} }
const state = signState({ ownerId: ctx.ownerId, provider: pid }) // Bind the OAuth round-trip to this browser: a random nonce goes into the
return NextResponse.redirect(prov.getAuthUrl(state)) // 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() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) 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({ const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id), 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 }) return NextResponse.json({ profile: profile ?? null })
+13 -1
View File
@@ -1,7 +1,13 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" 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" import { checkStorageLimit } from "@/lib/plan-limits"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"] 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 }) 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 // Enforce per-plan storage quota (accounts for everything already stored in
// the owner's portfolio namespace). // the owner's portfolio namespace).
const storageError = await checkStorageLimit(ownerId, file.size) const storageError = await checkStorageLimit(ownerId, file.size)
+2
View File
@@ -1,5 +1,6 @@
"use client" "use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react" import { useEffect } from "react"
export default function GlobalError({ export default function GlobalError({
@@ -10,6 +11,7 @@ export default function GlobalError({
reset: () => void reset: () => void
}) { }) {
useEffect(() => { useEffect(() => {
Sentry.captureException(error)
console.error(error) console.error(error)
}, [error]) }, [error])
+14 -2
View File
@@ -12,11 +12,23 @@ export default function manifest(): MetadataRoute.Manifest {
theme_color: "#09090b", theme_color: "#09090b",
icons: [ icons: [
{ {
src: "/logo-mark.png", src: "/icon-192.png",
type: "image/png", type: "image/png",
sizes: "100x100", sizes: "192x192",
purpose: "any", 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 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 { export default function sitemap(): MetadataRoute.Sitemap {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" // Build-time timestamp, refreshed on every deploy. We don't track per-page
const lastModified = new Date("2026-07-01") // modification dates, so a single honest "last built" date is used throughout.
const lastModified = new Date()
return [ return PAGES.map(({ path, changeFrequency, priority }) => ({
{ url: base, lastModified, changeFrequency: "weekly", priority: 1 }, url: path === "/" ? base : `${base}${path}`,
{ url: `${base}/tenant-portal-info`, lastModified, changeFrequency: "monthly", priority: 0.8 }, lastModified,
{ url: `${base}/api-docs`, lastModified, changeFrequency: "monthly", priority: 0.6 }, changeFrequency,
{ url: `${base}/signup`, lastModified, changeFrequency: "monthly", priority: 0.7 }, priority,
{ 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 },
]
} }
+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, highlight,
interval = "month", interval = "month",
annualAvailable = false, annualAvailable = false,
paypalEnabled = false,
}: { }: {
plan: string plan: string
label: string label: string
@@ -18,11 +17,8 @@ export function CheckoutButton({
// When true, show a monthly/annual choice. Only pass this for subscription // When true, show a monthly/annual choice. Only pass this for subscription
// plans and only when annual billing is actually configured server-side. // plans and only when annual billing is actually configured server-side.
annualAvailable?: boolean annualAvailable?: boolean
// When true, also offer "Pay with PayPal" using the same interval choice.
paypalEnabled?: boolean
}) { }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [paypalLoading, setPaypalLoading] = useState(false)
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval) const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
const effectiveInterval = annualAvailable ? chosenInterval : interval const effectiveInterval = annualAvailable ? chosenInterval : interval
@@ -39,21 +35,6 @@ export function CheckoutButton({
else setLoading(false) 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
{annualAvailable && ( {annualAvailable && (
@@ -82,7 +63,7 @@ export function CheckoutButton({
)} )}
<button <button
onClick={handleClick} onClick={handleClick}
disabled={loading || paypalLoading} disabled={loading}
className={cn( className={cn(
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50", "w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
highlight highlight
@@ -92,22 +73,6 @@ export function CheckoutButton({
> >
{loading ? "Loading..." : label} {loading ? "Loading..." : label}
</button> </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> </div>
) )
} }
+24 -8
View File
@@ -1,8 +1,9 @@
"use client" "use client"
import { useTransition } from "react" import { useTransition } from "react"
import Link from "next/link"
import { toast } from "sonner" 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 { formatDate } from "@/lib/utils"
import { sendLeaseForSignatureAction } from "@/app/actions/esign" import { sendLeaseForSignatureAction } from "@/app/actions/esign"
@@ -12,11 +13,12 @@ type Req = {
status: string status: string
signer_email: string signer_email: string
document_name: string | null document_name: string | null
signed_document_url: string | null
sent_at: string | null sent_at: string | null
completed_at: string | null completed_at: string | null
last_error: 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 }> = { 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 }, 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({ export function EsignLease({
leaseId, leaseId,
providers, connected,
requests, requests,
canSend, canSend,
disabledReason, disabledReason,
}: { }: {
leaseId: string leaseId: string
providers: Prov[] connected: Prov[]
requests: Req[] requests: Req[]
canSend: boolean canSend: boolean
disabledReason: string disabledReason: string
}) { }) {
const configured = providers.filter((p) => p.configured)
const [pending, start] = useTransition() const [pending, start] = useTransition()
function send(provider: string) { function send(provider: string) {
@@ -74,6 +75,16 @@ export function EsignLease({
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""} {r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""} {r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
</p> </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> </div>
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}> <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} <s.Icon className="h-3 w-3" /> {s.label}
@@ -84,11 +95,16 @@ export function EsignLease({
</ul> </ul>
)} )}
{configured.length === 0 ? ( {connected.length === 0 ? (
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p> <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 ? ( ) : canSend ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{configured.map((p) => ( {connected.map((p) => (
<button <button
key={p.id} key={p.id}
onClick={() => send(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" 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. // 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. // Keep these in sync with that source so the JSON-LD matches what users see.
const faqs = [ const faqs = [
@@ -43,6 +57,11 @@ const organization: Record<string, unknown> = {
name: "Property Management Network", name: "Property Management Network",
url: base, url: base,
logo: `${base}/logo-mark.png`, logo: `${base}/logo-mark.png`,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: "support@propertymanagement.network",
},
sameAs: [ sameAs: [
"https://twitter.com/propertymgmtnet", "https://twitter.com/propertymgmtnet",
"https://github.com/propertymanagement-network", "https://github.com/propertymanagement-network",
@@ -64,11 +83,7 @@ const softwareApplication: Record<string, unknown> = {
operatingSystem: "Web", operatingSystem: "Web",
description: description:
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.", "Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
offers: { offers: planOffers,
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
} }
const faqPage: Record<string, unknown> = { 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. // Next.js instrumentation hook — runs once when the server process starts.
// Surfaces "silently disabled" integrations at boot so a misconfigured deploy is // (1) Initializes Sentry for the active runtime, and (2) surfaces "silently
// obvious in the logs instead of failing quietly at runtime. // disabled" integrations at boot so a misconfigured deploy is obvious in the
export function register() { // logs instead of failing quietly at runtime.
// Only run in the Node.js server runtime (not edge/middleware) and only in prod. 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.NEXT_RUNTIME !== "nodejs") return
if (process.env.NODE_ENV !== "production") return if (process.env.NODE_ENV !== "production") return
@@ -33,4 +44,12 @@ export function register() {
if (!process.env.OPENAI_API_KEY) { if (!process.env.OPENAI_API_KEY) {
warn("OPENAI_API_KEY is NOT set — AI features will error when called.") 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" import crypto from "crypto"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider // Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider,
// and is tamper-proof, so the callback can't be forged/CSRF'd. // a random nonce (bound to a cookie by the connect route for CSRF protection),
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret" // and an issued-at timestamp so a leaked state can't be replayed indefinitely.
export function signState(data: { ownerId: string; provider: string }): string { const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url") // 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}` 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(".") const [payload, sig] = state.split(".")
if (!payload || !sig) return null 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 if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try { 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 { } catch {
return null return null
} }
+1
View File
@@ -12,6 +12,7 @@ export type AdminAction =
| "delete_user" | "delete_user"
| "resend_verification" | "resend_verification"
| "maintenance_mode" | "maintenance_mode"
| "ai_provider"
/** /**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating * 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" 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 // 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_API_KEY — it's only needed at runtime. Call sites keep using
// `openai.xxx` unchanged; the Proxy builds the real client on first access. // `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", "STRIPE_WEBHOOK_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"OPENAI_API_KEY", "OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"SMTP_HOST", "SMTP_HOST",
"SMTP_USER", "SMTP_USER",
"GOOGLE_CLIENT_ID", "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, "when": 1782994066547,
"tag": "0009_amusing_blackheart", "tag": "0009_amusing_blackheart",
"breakpoints": true "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(), 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) // 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 // DocuSign eSignature via per-landlord OAuth (Authorization Code Grant).
// or OAuth) — set DOCUSIGN_ACCESS_TOKEN / DOCUSIGN_ACCOUNT_ID / DOCUSIGN_BASE_URI. // The OPERATOR registers one DocuSign app and sets these; each LANDLORD then
// Docs: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/ // connects their own DocuSign account through it.
const ACCESS_TOKEN = process.env.DOCUSIGN_ACCESS_TOKEN ?? "" // DOCUSIGN_CLIENT_ID / DOCUSIGN_CLIENT_SECRET — the app's integration key + secret
const ACCOUNT_ID = process.env.DOCUSIGN_ACCOUNT_ID ?? "" // DOCUSIGN_OAUTH_BASE — "account-d.docusign.com" (demo) or "account.docusign.com" (prod)
const BASE_URI = (process.env.DOCUSIGN_BASE_URI ?? "https://demo.docusign.net").replace(/\/+$/, "") 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 { function extOf(name: string): string {
const e = name.split(".").pop()?.toLowerCase() const e = name.split(".").pop()?.toLowerCase()
return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf" 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 = { export const docusign: ESignAdapter = {
id: "docusign", id: "docusign",
label: "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 = { const envelope = {
emailSubject: subject, emailSubject: p.subject,
status: "sent", 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: { recipients: {
signers: [ signers: [
{ {
email: signerEmail, email: p.signerEmail,
name: signerName, name: p.signerName,
recipientId: "1", recipientId: "1",
routingOrder: "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" }] }, 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", method: "POST",
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "Content-Type": "application/json" }, headers: { Authorization: `Bearer ${creds.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(envelope), body: JSON.stringify(envelope),
}) })
if (!res.ok) throw new Error(`DocuSign ${res.status}: ${(await res.text()).slice(0, 300)}`) 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 } return { externalId: j.envelopeId }
}, },
parseWebhook(body): WebhookResult | null { peekExternalId(body): string | null {
// DocuSign Connect (JSON format) payload.
try { try {
const j = JSON.parse(body) as { event?: string; data?: { envelopeId?: string; envelopeSummary?: { status?: string } } } const j = JSON.parse(body) as { data?: { envelopeId?: string } }
const externalId = j.data?.envelopeId return j.data?.envelopeId ?? null
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
} catch { } catch {
return null 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: // Dropbox Sign (formerly HelloSign). Per-landlord API-key auth — each landlord
// https://developers.hellosign.com/api/reference/operation/signatureRequestSend/ // pastes their own API key (no platform app credentials needed). Docs:
const API_KEY = process.env.DROPBOX_SIGN_API_KEY ?? "" // https://developers.hellosign.com/api/reference/
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
const BASE = "https://api.hellosign.com/v3" const BASE = "https://api.hellosign.com/v3"
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
function auth() { function authFor(apiKey: string) {
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64") 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 = { export const dropboxSign: ESignAdapter = {
id: "dropbox_sign", id: "dropbox_sign",
label: "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() const fd = new FormData()
fd.append("subject", subject) fd.append("subject", p.subject)
fd.append("message", message) fd.append("message", p.message)
fd.append("test_mode", TEST_MODE) fd.append("test_mode", TEST_MODE)
fd.append("signers[0][email_address]", signerEmail) fd.append("signers[0][email_address]", p.signerEmail)
fd.append("signers[0][name]", signerName) fd.append("signers[0][name]", p.signerName)
fd.append("file[0]", new Blob([new Uint8Array(document)], { type: "application/pdf" }), documentName) 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)}`) 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 j = (await res.json()) as { signature_request?: { signature_request_id?: string } }
const id = j.signature_request?.signature_request_id const id = j.signature_request?.signature_request_id
@@ -32,22 +85,30 @@ export const dropboxSign: ESignAdapter = {
return { externalId: id } return { externalId: id }
}, },
parseWebhook(body): WebhookResult | null { peekExternalId(body): string | null {
// Dropbox Sign posts multipart form-data with a `json` field. return parseBody(body)?.signature_request?.signature_request_id ?? null
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. async verifyAndGetStatus(creds, _externalId, body): Promise<"signed" | "declined" | "voided" | null> {
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/) const ev = parseBody(body)
event = JSON.parse(m ? m[1] : body) if (!ev || !verifyEventHash(creds.accessToken, ev.event)) return null
} catch { 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 return null
} }
const type = event.event?.event_type },
const externalId = event.signature_request?.signature_request_id
if (!externalId || !type) return null async getSignedDocument(creds, externalId): Promise<Buffer | null> {
if (type === "signature_request_all_signed") return { externalId, status: "signed" } const res = await fetch(`${BASE}/signature_request/files/${externalId}?file_type=pdf`, {
if (type === "signature_request_declined") return { externalId, status: "declined" } headers: { Authorization: authFor(creds.accessToken) },
if (type === "signature_request_canceled") return { externalId, status: "voided" } })
return null 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 { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { leases, signature_requests } from "@/lib/db/schema" import { leases, signature_requests } from "@/lib/db/schema"
import { readFile } from "@/lib/storage" import { readFile, saveBuffer } from "@/lib/storage"
import { docusign } from "./docusign" import { getAdapter } from "./registry"
import { dropboxSign } from "./dropbox-sign" import { resolveEsignCreds, markEsignError } from "./credentials"
import type { ESignAdapter, ESignProvider } from "./types" import type { ESignProvider } from "./types"
export 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 { /** The webhook URL a provider should call back — used for DocuSign envelope-level Connect. */
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null function webhookUrl(provider: ESignProvider): string {
} const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/esign/${provider}/webhook`
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)
} }
/** Read the lease's stored document. Only /api/files keys are allowed (no SSRF). */
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> { async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
const prefix = "/api/files/" if (!documentUrl.startsWith(FILES_PREFIX)) {
if (documentUrl.startsWith(prefix)) { throw new Error("Lease document must be an uploaded file")
const key = documentUrl.slice(prefix.length) }
const key = documentUrl.slice(FILES_PREFIX.length)
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" } 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) { export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
const adapter = getAdapter(provider) const adapter = getAdapter(provider)
if (!adapter) throw new Error("Unknown 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({ const lease = await db.query.leases.findFirst({
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)), 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) const { bytes, name } = await getDocumentBytes(lease.document_url)
try { try {
const { externalId } = await adapter.send({ const { externalId } = await adapter.send(creds, {
document: bytes, document: bytes,
documentName: name, documentName: name,
signerEmail: email, signerEmail: email,
signerName, signerName,
subject: "Please sign your lease agreement", subject: "Please sign your lease agreement",
message: "Your landlord has sent your lease agreement for electronic signature.", message: "Your landlord has sent your lease agreement for electronic signature.",
webhookUrl: webhookUrl(provider),
}) })
const [row] = await db const [row] = await db
.insert(signature_requests) .insert(signature_requests)
@@ -66,6 +73,7 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
return row return row
} catch (e) { } catch (e) {
const msg = (e as Error).message.slice(0, 500) const msg = (e as Error).message.slice(0, 500)
await markEsignError(ownerId, provider, msg)
await db await db
.insert(signature_requests) .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 }) .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) { export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
const adapter = getAdapter(provider) const adapter = getAdapter(provider)
if (!adapter) return 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 await db
.update(signature_requests) .update(signature_requests)
.set({ .set({
status: result.status, status,
completed_at: result.status === "signed" ? new Date().toISOString() : null, completed_at: status === "signed" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(), 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 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 { export interface SendParams {
document: Buffer document: Buffer
documentName: string documentName: string
@@ -7,20 +29,54 @@ export interface SendParams {
signerName: string signerName: string
subject: string subject: string
message: string message: string
} /** Our callback the provider should ping on status changes (DocuSign envelope-level Connect). */
webhookUrl: string
export interface WebhookResult {
externalId: string
status: "signed" | "declined" | "voided"
} }
export interface ESignAdapter { export interface ESignAdapter {
id: ESignProvider id: ESignProvider
label: string label: string
/** True when this provider's credentials are configured in env. */ /** "oauth" → connect via redirect; "apikey" → connect by pasting a key. */
configured(): boolean kind: "oauth" | "apikey"
/** Send a document for signature; returns the provider's request/envelope id. */ /**
send(p: SendParams): Promise<{ externalId: string }> * True when the platform can offer this provider. OAuth providers need the
/** Parse an inbound webhook body into a status update (or null to ignore). */ * operator's app credentials (client id/secret); API-key providers are always
parseWebhook(body: string, headers: Headers): WebhookResult | null * 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, "_") 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> { async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
if (!body) return Buffer.alloc(0) if (!body) return Buffer.alloc(0)
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body. // 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 } 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> { export async function readFile(key: string): Promise<Buffer> {
if (usingSpaces()) { if (usingSpaces()) {
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) })) const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
@@ -227,7 +304,10 @@ export async function presignGetUrl(
return toCdnUrl(signed) 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 { try {
if (usingSpaces()) { if (usingSpaces()) {
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) })) 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 type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
// NOTE: The Content-Security-Policy is set per-request in `proxy.ts` (Next // 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 // 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" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.110.0",
"@aws-sdk/client-s3": "^3.1077.0", "@aws-sdk/client-s3": "^3.1077.0",
"@aws-sdk/s3-request-presigner": "^3.1078.0", "@aws-sdk/s3-request-presigner": "^3.1078.0",
"@fullcalendar/daygrid": "^6.1.21", "@fullcalendar/daygrid": "^6.1.21",
@@ -22,6 +23,7 @@
"@fullcalendar/react": "^6.1.21", "@fullcalendar/react": "^6.1.21",
"@fullcalendar/timegrid": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@sentry/nextjs": "^10.63.0",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"better-auth": "^1.6.20", "better-auth": "^1.6.20",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+71 -3
View File
@@ -28,6 +28,19 @@ const PROTECTED_PATHS = [
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"] 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 // Build the per-request Content-Security-Policy. `script-src` carries a
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps // per-request nonce instead of 'unsafe-inline'. `style-src` keeps
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline // 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline
@@ -36,6 +49,7 @@ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
// scripts automatically. // scripts automatically.
function buildCsp(nonce: string): string { function buildCsp(nonce: string): string {
const isDev = process.env.NODE_ENV !== "production" const isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin()
// In development, Next.js/React and Turbopack HMR require eval() for hot // In development, Next.js/React and Turbopack HMR require eval() for hot
// reloading and debugging features, and open a dev websocket. These are NOT // reloading and debugging features, and open a dev websocket. These are NOT
@@ -43,9 +57,14 @@ function buildCsp(nonce: string): string {
const scriptSrc = isDev const scriptSrc = isDev
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com` ? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com`
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com` : `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`
const connectSrc = isDev const connectSrc = [
? "connect-src 'self' ws: wss: https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com" "connect-src 'self'",
: "connect-src 'self' https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com" isDev ? "ws: wss:" : "",
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
sentry ?? "",
]
.filter(Boolean)
.join(" ")
return [ return [
"default-src 'self'", "default-src 'self'",
@@ -54,13 +73,39 @@ function buildCsp(nonce: string): string {
scriptSrc, scriptSrc,
"font-src 'self' data:", "font-src 'self' data:",
connectSrc, 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-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
"frame-ancestors 'none'", "frame-ancestors 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
"object-src 'none'",
].join("; ") ].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) { export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname const pathname = request.nextUrl.pathname
@@ -76,11 +121,19 @@ export async function proxy(request: NextRequest) {
} }
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p)) const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
let dropStaleSessionCookie = false
if (isAuthPage && sessionCookie) { if (isAuthPage && sessionCookie) {
const state = await sessionState(request)
if (state === "valid") {
const url = request.nextUrl.clone() const url = request.nextUrl.clone()
url.pathname = "/dashboard" url.pathname = "/dashboard"
return NextResponse.redirect(url) 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 // Per-request CSP nonce. UUID contains only hex + dashes, so it never
// includes HTML-escape characters (which Next rejects in nonces). // 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 } }) const response = NextResponse.next({ request: { headers: requestHeaders } })
// Also set the CSP on the outgoing response so the browser enforces it. // Also set the CSP on the outgoing response so the browser enforces it.
response.headers.set("Content-Security-Policy", csp) 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 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,
})