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>
@@ -1,30 +1,28 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# DigitalOcean App Platform spec — Property Management Network
|
||||
#
|
||||
# Deploy: doctl apps create --spec .do/app.yaml
|
||||
# Deploy: doctl apps create --spec .do/app.yaml (or the DO MCP apps-create)
|
||||
# Update: doctl apps update <APP_ID> --spec .do/app.yaml
|
||||
#
|
||||
# SOURCE: image-based from DigitalOcean Container Registry (DOCR). The app's git
|
||||
# lives on self-hosted Gitea, which App Platform cannot pull, so we build the
|
||||
# Docker image ourselves and push it to DOCR. See DIGITALOCEAN.md for the full
|
||||
# build/push/deploy walkthrough.
|
||||
# SOURCE: App Platform builds the Dockerfile directly from GitHub
|
||||
# (github.com/silkoserfo/property-management-network). Pushes to `main`
|
||||
# auto-redeploy (deploy_on_push). No DOCR image build/push needed.
|
||||
#
|
||||
# SECRETS: values marked `type: SECRET` are placeholders — set the real values in
|
||||
# the App Platform dashboard (App → Settings → Environment Variables) or via
|
||||
# `doctl`. Never commit real secrets to this file.
|
||||
# the App Platform dashboard (App → Settings → Environment Variables) or via the
|
||||
# create spec. Never commit real secrets to this file.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: property-management-network
|
||||
region: nyc
|
||||
|
||||
services:
|
||||
- name: web
|
||||
# Pre-built image pushed to DOCR (repository must exist in your registry).
|
||||
image:
|
||||
registry_type: DOCR
|
||||
repository: property-management-network
|
||||
tag: latest
|
||||
deploy_on_push:
|
||||
enabled: true
|
||||
# Built by App Platform from GitHub using the repo Dockerfile.
|
||||
github:
|
||||
repo: silkoserfo/property-management-network
|
||||
branch: main
|
||||
deploy_on_push: true
|
||||
dockerfile_path: Dockerfile
|
||||
instance_count: 1
|
||||
instance_size_slug: apps-s-1vcpu-1gb
|
||||
http_port: 3000
|
||||
@@ -37,32 +35,35 @@ services:
|
||||
failure_threshold: 3
|
||||
envs:
|
||||
# ── App URLs ──────────────────────────────────────────────────────────
|
||||
# ${APP_URL} resolves to the app's public URL at runtime. NOTE: the client
|
||||
# bundle bakes NEXT_PUBLIC_APP_URL at *image build* time (see Dockerfile /
|
||||
# DIGITALOCEAN.md), so build the image with the same URL you serve on.
|
||||
# NEXT_PUBLIC_* are inlined into the client bundle at BUILD time, so they
|
||||
# must be RUN_AND_BUILD_TIME with the literal domain we serve on.
|
||||
- key: NEXT_PUBLIC_APP_URL
|
||||
scope: RUN_TIME
|
||||
value: ${APP_URL}
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: https://propertymanagement.network
|
||||
- key: BETTER_AUTH_URL
|
||||
scope: RUN_TIME
|
||||
value: ${APP_URL}
|
||||
value: https://propertymanagement.network
|
||||
- key: NEXT_PUBLIC_APP_NAME
|
||||
scope: RUN_TIME
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: Property Management Network
|
||||
|
||||
# ── Database (managed Postgres — use the PRIVATE host; see DIGITALOCEAN.md) ──
|
||||
# ── Admin & auth policy ───────────────────────────────────────────────
|
||||
- key: ADMIN_EMAILS
|
||||
scope: RUN_TIME
|
||||
value: leon@phluit.com
|
||||
- key: REQUIRE_EMAIL_VERIFICATION
|
||||
scope: RUN_TIME
|
||||
value: "true"
|
||||
|
||||
# ── Database (managed Postgres — PRIVATE host, direct port 25060) ──
|
||||
- key: DATABASE_URL
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
# Verified TLS (encrypted + certificate-checked). DO Managed Postgres uses
|
||||
# a CA that isn't in the system trust store, so paste the cluster's CA cert
|
||||
# into DATABASE_CA: DO control panel → Database → Connection Details →
|
||||
# "Download CA certificate", then paste its PEM contents as the DATABASE_CA
|
||||
# secret in the App Platform dashboard. Without a valid CA the app will
|
||||
# refuse to connect (fail loud) rather than run unverified.
|
||||
# Emergency fallback ONLY (not for production): DATABASE_SSL=no-verify is
|
||||
# encrypted but does NOT verify the server certificate.
|
||||
# Verified TLS: DO's Managed Postgres CA isn't in the system trust store, so
|
||||
# paste the cluster CA PEM (repo root ca-certificate.crt) into DATABASE_CA.
|
||||
# With `require` + a valid CA the app connects verified; without a valid CA
|
||||
# it fails loud rather than run unverified.
|
||||
- key: DATABASE_SSL
|
||||
scope: RUN_TIME
|
||||
value: require
|
||||
@@ -70,8 +71,7 @@ services:
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
# Schema is migrated out-of-band (as doadmin), NOT on boot — the app user
|
||||
# intentionally lacks DDL rights. Keep this false; run migrations manually.
|
||||
# Schema is migrated out-of-band (as doadmin), NOT on boot.
|
||||
- key: RUN_MIGRATIONS_ON_START
|
||||
scope: RUN_TIME
|
||||
value: "false"
|
||||
@@ -81,14 +81,15 @@ services:
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
# Google OAuth (optional — leave blank to disable the Google button).
|
||||
- key: GOOGLE_CLIENT_ID
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
value: ""
|
||||
- key: GOOGLE_CLIENT_SECRET
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
value: ""
|
||||
|
||||
# ── Stripe ────────────────────────────────────────────────────────────
|
||||
- key: STRIPE_SECRET_KEY
|
||||
@@ -99,22 +100,21 @@ services:
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
# No STRIPE_*_PRICE_ID vars — prices are resolved by lookup key and
|
||||
# auto-created on first checkout (lib/stripe/prices.ts). Going live only
|
||||
# needs the two live secrets above + the live publishable key below.
|
||||
|
||||
# ── OpenAI ────────────────────────────────────────────────────────────
|
||||
- key: OPENAI_API_KEY
|
||||
# ── AI provider (Anthropic default; OpenAI optional) ──────────────────
|
||||
- key: ANTHROPIC_API_KEY
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
- key: ANTHROPIC_MODEL
|
||||
scope: RUN_TIME
|
||||
value: claude-haiku-4-5
|
||||
- key: OPENAI_API_KEY
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: ""
|
||||
|
||||
# ── Email (SMTP — SMTP2GO) ────────────────────────────────────────────
|
||||
# The app sends mail via SMTP only (nodemailer). Email is silently skipped
|
||||
# unless SMTP_HOST + SMTP_USER + SMTP_PASS are all set — password resets,
|
||||
# email verification, rent/overdue/lease reminders, team invites, and
|
||||
# payment links all depend on this. EMAIL_FROM is a bare address; the app
|
||||
# wraps it as "Property Management Network <…>".
|
||||
- key: SMTP_HOST
|
||||
scope: RUN_TIME
|
||||
value: mail.smtp2go.com
|
||||
@@ -133,10 +133,9 @@ services:
|
||||
scope: RUN_TIME
|
||||
value: postmaster@propertymanagement.network
|
||||
|
||||
# ── Cloudflare Turnstile (site key is public; baked into the client bundle
|
||||
# at image build time — keep it in sync when you build) ──
|
||||
# ── Cloudflare Turnstile (site key public; baked at build time) ──
|
||||
- key: NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
scope: RUN_TIME
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: 0x4AAAAAADuDQverznfv1a60
|
||||
- key: TURNSTILE_SECRET_KEY
|
||||
scope: RUN_TIME
|
||||
@@ -165,8 +164,53 @@ services:
|
||||
scope: RUN_TIME
|
||||
value: https://nyc3.cdn.digitaloceanspaces.com
|
||||
|
||||
# ── Accounting sync (optional — per-landlord QuickBooks / Xero OAuth) ──
|
||||
- key: QBO_CLIENT_ID
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: ""
|
||||
- key: QBO_CLIENT_SECRET
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: ""
|
||||
- key: QBO_ENVIRONMENT
|
||||
scope: RUN_TIME
|
||||
value: production
|
||||
- key: XERO_CLIENT_ID
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: ""
|
||||
- key: XERO_CLIENT_SECRET
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: ""
|
||||
- key: XERO_SALES_ACCOUNT_CODE
|
||||
scope: RUN_TIME
|
||||
value: "200"
|
||||
- key: XERO_EXPENSE_ACCOUNT_CODE
|
||||
scope: RUN_TIME
|
||||
value: "400"
|
||||
|
||||
# ── Error monitoring (Sentry — DSN is public; browser DSN baked at build) ──
|
||||
- key: SENTRY_DSN
|
||||
scope: RUN_TIME
|
||||
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
|
||||
- key: NEXT_PUBLIC_SENTRY_DSN
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
|
||||
- key: SENTRY_ENVIRONMENT
|
||||
scope: RUN_TIME
|
||||
value: production
|
||||
|
||||
# ── Cron (Bearer token the DO Function sends to /api/cron/*) ──
|
||||
- key: CRON_SECRET
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: REPLACE_IN_DASHBOARD
|
||||
|
||||
# ── Custom domains (DNS hosted on Cloudflare — set CNAMEs there, DNS-only) ──
|
||||
domains:
|
||||
- domain: propertymanagement.network
|
||||
type: PRIMARY
|
||||
- domain: www.propertymanagement.network
|
||||
type: ALIAS
|
||||
|
||||
@@ -50,24 +50,16 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key
|
||||
# auto-creates them on first checkout (lib/stripe/prices.ts), so going live is a
|
||||
# pure key swap. Optionally pre-create the catalog: node scripts/stripe-setup.mjs
|
||||
|
||||
# === PAYPAL (optional — alternative subscription checkout) ===
|
||||
# Lets landlords pay for their plan with PayPal alongside Stripe. Leave blank to
|
||||
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com;
|
||||
# keep PAYPAL_ENVIRONMENT=sandbox for testing. Create a webhook pointing to
|
||||
# <APP_URL>/api/paypal/webhook and set its id as PAYPAL_WEBHOOK_ID. Generate the
|
||||
# plan IDs once with `node scripts/paypal-setup-plans.mjs` and paste them below.
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_ENVIRONMENT=sandbox
|
||||
PAYPAL_WEBHOOK_ID=
|
||||
PAYPAL_PRO_MONTHLY_PLAN_ID=
|
||||
PAYPAL_PRO_YEARLY_PLAN_ID=
|
||||
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
|
||||
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
|
||||
|
||||
# === AI (OpenAI) ===
|
||||
# Get from: https://platform.openai.com/api-keys
|
||||
# === AI PROVIDER (OpenAI and/or Anthropic) ===
|
||||
# The active provider is chosen by an admin in Settings → System. Configure the
|
||||
# key(s) for whichever provider(s) you want available; the app falls back to the
|
||||
# configured one if the selected provider's key is missing.
|
||||
# OpenAI — https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=sk-your-api-key
|
||||
# OPENAI_MODEL=gpt-4o-mini
|
||||
# Anthropic (Claude) — https://console.anthropic.com/settings/keys
|
||||
ANTHROPIC_API_KEY=
|
||||
# ANTHROPIC_MODEL=claude-haiku-4-5 # cheapest; use claude-sonnet-5 / claude-opus-4-8 for more capability
|
||||
|
||||
# === EMAIL (SMTP — e.g. SMTP2GO) ===
|
||||
# Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS.
|
||||
@@ -87,16 +79,18 @@ QBO_ENVIRONMENT=sandbox
|
||||
XERO_CLIENT_ID=
|
||||
XERO_CLIENT_SECRET=
|
||||
|
||||
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) ===
|
||||
# Dropbox Sign: API-key auth. Set DROPBOX_SIGN_TEST_MODE=true while testing.
|
||||
DROPBOX_SIGN_API_KEY=
|
||||
DROPBOX_SIGN_TEST_MODE=true
|
||||
# DocuSign: uses a pre-obtained access token (JWT/OAuth). Webhook: DocuSign
|
||||
# Connect → <APP_URL>/api/esign/docusign/webhook ; Dropbox Sign callback →
|
||||
# <APP_URL>/api/esign/dropbox_sign/webhook
|
||||
DOCUSIGN_ACCESS_TOKEN=
|
||||
DOCUSIGN_ACCOUNT_ID=
|
||||
DOCUSIGN_BASE_URI=https://demo.docusign.net
|
||||
# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
|
||||
# DocuSign: register ONE DocuSign app (integration key) here; each landlord then
|
||||
# connects their own DocuSign account via OAuth from Settings → Integrations.
|
||||
# Redirect URI to register in the DocuSign app: <APP_URL>/api/esign/docusign/callback
|
||||
# DOCUSIGN_OAUTH_BASE: account-d.docusign.com (demo) or account.docusign.com (prod).
|
||||
DOCUSIGN_CLIENT_ID=
|
||||
DOCUSIGN_CLIENT_SECRET=
|
||||
DOCUSIGN_OAUTH_BASE=account-d.docusign.com
|
||||
# Dropbox Sign: no server credentials — landlords paste their own API key in the
|
||||
# app and set their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
|
||||
# DROPBOX_SIGN_TEST_MODE applies test mode to all outbound requests (optional).
|
||||
DROPBOX_SIGN_TEST_MODE=false
|
||||
|
||||
# === APP ===
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
@@ -113,6 +107,14 @@ CRON_SECRET=your-random-secret-string
|
||||
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
|
||||
|
||||
# === ERROR MONITORING (Sentry — optional) ===
|
||||
# Paste the DSN from your Sentry project (Settings → Client Keys / DSN). It's
|
||||
# public (ships in the browser bundle). Sentry stays inert until this is set.
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
# Build-time only: uploads source maps for readable stack traces. Create at
|
||||
# Sentry → Settings → Auth Tokens. Keep secret; leave blank to skip upload.
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
|
||||
# Property addresses are geocoded on save via OpenStreetMap Nominatim and shown
|
||||
# on a Leaflet map (both keyless & free). Nominatim's policy requires an
|
||||
|
||||
@@ -68,21 +68,6 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
|
||||
# first checkout, so going live is ONLY the three values above (live keys + live
|
||||
# webhook secret). Optionally pre-create the catalog: node scripts/stripe-setup.mjs
|
||||
|
||||
# === PAYPAL (optional — alternative subscription checkout) ===
|
||||
# Landlords can pay for their plan with PayPal alongside Stripe. Leave blank to
|
||||
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com and
|
||||
# set PAYPAL_ENVIRONMENT=live for production. Create a webhook there pointing to
|
||||
# <APP_URL>/api/paypal/webhook and put its id in PAYPAL_WEBHOOK_ID. Generate the
|
||||
# plan IDs with `node scripts/paypal-setup-plans.mjs`.
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_ENVIRONMENT=live
|
||||
PAYPAL_WEBHOOK_ID=
|
||||
PAYPAL_PRO_MONTHLY_PLAN_ID=
|
||||
PAYPAL_PRO_YEARLY_PLAN_ID=
|
||||
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
|
||||
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
|
||||
|
||||
# === AI (OpenAI) ===
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
|
||||
@@ -103,17 +88,17 @@ QBO_ENVIRONMENT=production
|
||||
XERO_CLIENT_ID=
|
||||
XERO_CLIENT_SECRET=
|
||||
|
||||
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) ===
|
||||
# Leave blank to hide/disable a provider on the lease page. Configure the
|
||||
# provider callbacks to point at this app:
|
||||
# Dropbox Sign callback → <APP_URL>/api/esign/dropbox_sign/webhook
|
||||
# DocuSign Connect → <APP_URL>/api/esign/docusign/webhook
|
||||
# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs.
|
||||
DROPBOX_SIGN_API_KEY=
|
||||
# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
|
||||
# DocuSign: register ONE DocuSign app; landlords connect their own account via
|
||||
# OAuth from Settings → Integrations. Register this redirect URI in the app:
|
||||
# <APP_URL>/api/esign/docusign/callback
|
||||
# Use account.docusign.com in production (account-d.docusign.com for demo).
|
||||
DOCUSIGN_CLIENT_ID=
|
||||
DOCUSIGN_CLIENT_SECRET=
|
||||
DOCUSIGN_OAUTH_BASE=account.docusign.com
|
||||
# Dropbox Sign: no server credentials — landlords paste their own API key and set
|
||||
# their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
|
||||
DROPBOX_SIGN_TEST_MODE=false
|
||||
DOCUSIGN_ACCESS_TOKEN=
|
||||
DOCUSIGN_ACCOUNT_ID=
|
||||
DOCUSIGN_BASE_URI=https://www.docusign.net
|
||||
|
||||
# === APP (NEXT_PUBLIC_* — also set as Build Variables) ===
|
||||
NEXT_PUBLIC_APP_URL=https://propertymanagement.network
|
||||
@@ -128,6 +113,14 @@ GOOGLE_SITE_VERIFICATION=
|
||||
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
|
||||
|
||||
# === ERROR MONITORING (Sentry) ===
|
||||
# DSN from your Sentry project (public — inlined in the browser bundle, so set
|
||||
# it as a Build Variable too). Error monitoring is disabled until this is set.
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
# Build-time secret: uploads source maps so prod stack traces are un-minified.
|
||||
# Sentry → Settings → Auth Tokens. Set as a Build Variable; leave blank to skip.
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
|
||||
# Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM
|
||||
# tiles. No API key or billing. Nominatim REQUIRES an identifying User-Agent —
|
||||
|
||||
@@ -34,6 +34,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# MCP config — contains a DigitalOcean API token; keep local, never commit.
|
||||
.mcp.json
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
@@ -94,11 +94,19 @@ docker build \
|
||||
--build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
|
||||
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
|
||||
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_DSN=<your-sentry-dsn> \
|
||||
--build-arg SENTRY_AUTH_TOKEN=<optional-for-source-maps> \
|
||||
-t $REG/property-management-network:latest .
|
||||
|
||||
docker push $REG/property-management-network:latest
|
||||
```
|
||||
|
||||
> **Sentry:** the browser DSN is baked at build time, so it must be a `--build-arg`
|
||||
> (setting `NEXT_PUBLIC_SENTRY_DSN` only in the dashboard won't reach the client). The
|
||||
> server/edge runtimes read `SENTRY_DSN` at runtime (set in the dashboard). Both stay inert
|
||||
> until a DSN is provided, so it's safe to omit until you're ready. `SENTRY_AUTH_TOKEN` is
|
||||
> optional and only uploads source maps for readable stack traces.
|
||||
|
||||
> First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the
|
||||
> `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`.
|
||||
|
||||
@@ -114,7 +122,10 @@ Then set every `type: SECRET` value (App → Settings → Environment Variables)
|
||||
`.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`,
|
||||
`BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`,
|
||||
`OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`,
|
||||
`SPACES_SECRET`, `CRON_SECRET` (plus the Stripe price IDs). Email sends via **SMTP
|
||||
`SPACES_SECRET`, `CRON_SECRET`. Optional integrations (leave blank to keep hidden):
|
||||
`QBO_CLIENT_ID/SECRET` + `XERO_CLIENT_ID/SECRET` (accounting), `DOCUSIGN_CLIENT_ID/SECRET`
|
||||
(e-signature — not yet in the spec; add if used), and `SENTRY_DSN` (error monitoring —
|
||||
plus the `NEXT_PUBLIC_SENTRY_DSN` build-arg above). Email sends via **SMTP
|
||||
(SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without
|
||||
`SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves
|
||||
for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime.
|
||||
|
||||
@@ -24,9 +24,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ARG NEXT_PUBLIC_APP_URL
|
||||
ARG NEXT_PUBLIC_APP_NAME="Property Management Network"
|
||||
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
# Client-side Sentry DSN — inlined into the browser bundle. Without it, only
|
||||
# server/edge errors are reported (SENTRY_DSN at runtime); the browser stays inert.
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
# Optional: a Sentry auth token uploads source maps for readable stack traces.
|
||||
# The build still succeeds without it.
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -32,7 +32,7 @@ Everything is **multi-tenant and team-aware**: each landlord operates on their o
|
||||
**Core operations**
|
||||
- 🏢 **Properties & units** — manage your whole portfolio with live occupancy tracking and a map view (addresses are auto-geocoded).
|
||||
- 👥 **Tenants** — profiles, lease history, payment records, and a private **tenant portal** (token-based, no login required).
|
||||
- 💵 **Rent tracking** — log payments, send **Stripe or PayPal** payment links, and auto-mark balances overdue with automatic late fees.
|
||||
- 💵 **Rent tracking** — log payments, send **Stripe** payment links, and auto-mark balances overdue with automatic late fees.
|
||||
- 🔧 **Maintenance** — full status workflow (Open → In Progress → Resolved), with tenant-submitted requests from the portal.
|
||||
- 📄 **Leases** — expiry countdowns, automated 60/30/7-day email alerts, and **e-signature** (DocuSign / Dropbox Sign).
|
||||
- 🧾 **Expenses** — categorized logging with recurring-expense support.
|
||||
@@ -55,7 +55,7 @@ Everything is **multi-tenant and team-aware**: each landlord operates on their o
|
||||
|---|---|
|
||||
| 🌐 **Public REST API** | Versioned `/api/v1` endpoints (properties, tenants, payments, maintenance, webhooks) authenticated with Bearer **API keys**. See `/api-docs`. |
|
||||
| 🪝 **Outbound webhooks / Zapier** | Subscribe to events (`tenant.created`, `payment.paid`, `maintenance.updated`, …). Deliveries are **HMAC-signed**, retried with backoff, and Zapier-compatible via the REST-hook subscribe/unsubscribe pattern. |
|
||||
| 💳 **Payments** | Stripe (subscriptions + rent payment links) and PayPal. |
|
||||
| 💳 **Payments** | Stripe (subscriptions + rent payment links). |
|
||||
| 📚 **Accounting sync** | One-way push of income & expenses to **QuickBooks Online** or **Xero** (OAuth). |
|
||||
| ✍️ **E-signature** | Send leases for signature via **DocuSign** or **Dropbox Sign**. |
|
||||
| 🔑 **Auth** | Email/password and Google OAuth (Better Auth). |
|
||||
@@ -73,7 +73,7 @@ Every integration is env-gated: unconfigured providers show a clean “not confi
|
||||
| 🏆 **Landlord** | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) |
|
||||
| ♾️ **Lifetime** | $199 once | Everything in Landlord, forever |
|
||||
|
||||
Billing runs through **Stripe** or **PayPal**. Stripe products/prices are resolved by stable lookup keys and auto-created on first checkout, so going live is just an API-key swap — no price IDs to wire up.
|
||||
Billing runs through **Stripe**. Products/prices are resolved by stable lookup keys and auto-created on first checkout, so going live is just an API-key swap — no price IDs to wire up.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +86,7 @@ Billing runs through **Stripe** or **PayPal**. Stripe products/prices are resolv
|
||||
| Database | PostgreSQL via **Drizzle ORM** |
|
||||
| Auth | Better Auth (email/password + Google OAuth) |
|
||||
| Object storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) |
|
||||
| Payments | Stripe + PayPal |
|
||||
| Payments | Stripe |
|
||||
| AI | OpenAI (`gpt-4o-mini`) |
|
||||
| Email | SMTP (SMTP2GO) |
|
||||
| Maps | Leaflet + OpenStreetMap / Nominatim geocoding |
|
||||
@@ -113,7 +113,7 @@ Copy the template and fill in your own values:
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
`.env.local` holds your database URL, auth secret, and credentials for Stripe/PayPal, OpenAI, SMTP, and object storage. **Every variable is documented inline in `.env.example`**, and the full production reference lives in **[DIGITALOCEAN.md](DIGITALOCEAN.md)**. Never commit real secrets.
|
||||
`.env.local` holds your database URL, auth secret, and credentials for Stripe, OpenAI, SMTP, and object storage. **Every variable is documented inline in `.env.example`**, and the full production reference lives in **[DIGITALOCEAN.md](DIGITALOCEAN.md)**. Never commit real secrets.
|
||||
|
||||
### 3. 🗄️ Run migrations
|
||||
|
||||
@@ -129,7 +129,7 @@ npm run db:push # push schema directly (quick local prototyping)
|
||||
|
||||
- **Stripe** — set the API keys, then add a webhook at `https://yourdomain.com/api/stripe/webhook` for `checkout.session.completed`, the `customer.subscription.*` events, `invoice.payment_failed`, and `payment_intent.succeeded`.
|
||||
- **Email** — verify a sending domain with your SMTP provider (e.g. SMTP2GO) and set the `SMTP_*` + `EMAIL_FROM` vars.
|
||||
- **Google / PayPal / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present.
|
||||
- **Google / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present.
|
||||
|
||||
### 5. ▶️ Run locally
|
||||
|
||||
@@ -158,7 +158,7 @@ app/
|
||||
├── api/
|
||||
│ ├── v1/ # 🌐 Public REST API (Bearer API keys)
|
||||
│ ├── webhooks + cron/ # 🪝 Outbound webhook delivery + scheduled jobs
|
||||
│ ├── stripe/ paypal/ # 💳 Billing + payment links + provider webhooks
|
||||
│ ├── stripe/ # 💳 Billing + payment links + provider webhooks
|
||||
│ ├── integrations/ # 📚 QuickBooks / Xero OAuth
|
||||
│ ├── esign/ # ✍️ DocuSign / Dropbox Sign
|
||||
│ └── … # Properties, tenants, rent, maintenance, documents, AI
|
||||
@@ -169,7 +169,7 @@ lib/
|
||||
├── auth.ts account.ts # Better Auth + team/account scoping
|
||||
├── storage.ts # Object storage (Spaces) with local-disk dev fallback
|
||||
├── webhooks/ # Event catalog, HMAC signing, SSRF guard, delivery
|
||||
├── stripe/ paypal/ # Billing clients & plans
|
||||
├── stripe/ # Billing clients & plans
|
||||
├── accounting/ esign/ # QuickBooks/Xero & DocuSign/Dropbox Sign
|
||||
├── ai/ # OpenAI client + prompts
|
||||
├── email/ # SMTP (SMTP2GO) client + HTML templates
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries"
|
||||
import { getMaintenanceMode } from "@/lib/settings"
|
||||
import { aiProviderStatus } from "@/lib/ai/provider"
|
||||
import { MaintenanceToggle } from "@/components/admin/maintenance-toggle"
|
||||
import { AiProviderToggle } from "@/components/admin/ai-provider-toggle"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { Settings, Database, Table2 } from "lucide-react"
|
||||
|
||||
@@ -13,10 +15,11 @@ function humanize(name: string): string {
|
||||
}
|
||||
|
||||
export default async function AdminSystemPage() {
|
||||
const [{ counts, cronLastRun }, env, maintenance] = await Promise.all([
|
||||
const [{ counts, cronLastRun }, env, maintenance, aiProvider] = await Promise.all([
|
||||
getSystemCounts(),
|
||||
Promise.resolve(getEnvHealth()),
|
||||
getMaintenanceMode(),
|
||||
aiProviderStatus(),
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -37,6 +40,18 @@ export default async function AdminSystemPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI provider selection */}
|
||||
<div className="mb-5">
|
||||
<AiProviderToggle
|
||||
selected={aiProvider.selected}
|
||||
effective={aiProvider.effective}
|
||||
openaiConfigured={aiProvider.openaiConfigured}
|
||||
anthropicConfigured={aiProvider.anthropicConfigured}
|
||||
openaiModel={aiProvider.openaiModel}
|
||||
anthropicModel={aiProvider.anthropicModel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
|
||||
{/* ── Environment configuration ───────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { Logo } from "@/components/shared/logo"
|
||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
|
||||
import { resetPassword } from "@/app/actions/auth"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reset password",
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { Logo } from "@/components/shared/logo"
|
||||
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
|
||||
import { signIn, signInWithGoogle } from "@/app/actions/auth"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in",
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -4,13 +4,16 @@ import { db } from "@/lib/db"
|
||||
import { leases as leasesTable } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { listAdapters, listRequestsForLease } from "@/lib/esign"
|
||||
import { listEsignConnections, listRequestsForLease } from "@/lib/esign"
|
||||
import Link from "next/link"
|
||||
import { FileText, ExternalLink } from "lucide-react"
|
||||
import { FileText } from "lucide-react"
|
||||
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LeaseActions } from "@/components/forms/lease-actions"
|
||||
import { EsignLease } from "@/components/forms/esign-lease"
|
||||
import { LeaseDocument } from "@/components/forms/lease-document"
|
||||
|
||||
const ESIGN_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
|
||||
|
||||
export const metadata = { title: "Lease" }
|
||||
|
||||
@@ -56,8 +59,12 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
|
||||
if (!lease) notFound()
|
||||
|
||||
const esignRequests = await listRequestsForLease(ownerId, leaseId)
|
||||
const esignProviders = listAdapters()
|
||||
const canSendEsign = ctx.canWrite && !!lease.document_url && !!lease.tenant?.email
|
||||
const esignConnections = await listEsignConnections(ownerId)
|
||||
const connectedProviders = esignConnections
|
||||
.filter((c) => c.status !== "revoked")
|
||||
.map((c) => ({ id: c.provider, label: ESIGN_LABEL[c.provider] ?? c.provider }))
|
||||
const canSendEsign =
|
||||
ctx.canWrite && !!lease.document_url && !!lease.tenant?.email && connectedProviders.length > 0
|
||||
const esignDisabledReason = !ctx.canWrite
|
||||
? "You have read-only access."
|
||||
: !lease.document_url
|
||||
@@ -196,24 +203,11 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lease.document_url && (
|
||||
<a
|
||||
href={lease.document_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between rounded-xl border border-white/[0.06] bg-[#16161f] p-5 transition hover:border-white/15"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
Lease document
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-white/40" />
|
||||
</a>
|
||||
)}
|
||||
<LeaseDocument leaseId={leaseId} documentUrl={lease.document_url} canWrite={ctx.canWrite} />
|
||||
|
||||
<EsignLease
|
||||
leaseId={leaseId}
|
||||
providers={esignProviders}
|
||||
connected={connectedProviders}
|
||||
requests={esignRequests}
|
||||
canSend={canSendEsign}
|
||||
disabledReason={esignDisabledReason}
|
||||
|
||||
@@ -5,9 +5,7 @@ import { profiles, properties, tenants } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { CheckoutButton } from "@/components/forms/checkout-button"
|
||||
import { PortalButton } from "@/components/forms/portal-button"
|
||||
import { PaypalCancelButton } from "@/components/forms/paypal-cancel-button"
|
||||
import { getPlanLabel, PLAN_LIMITS, annualEnabled } from "@/lib/stripe/plans"
|
||||
import { paypalConfigured } from "@/lib/paypal/client"
|
||||
import { Check } from "lucide-react"
|
||||
import type { Plan } from "@/types"
|
||||
|
||||
@@ -59,7 +57,7 @@ const PLANS = [
|
||||
export default async function BillingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ success?: string; canceled?: string; error?: string }>
|
||||
searchParams: Promise<{ success?: string; canceled?: string }>
|
||||
}) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
@@ -72,16 +70,12 @@ export default async function BillingPage({
|
||||
plan_expires_at: true,
|
||||
stripe_customer_id: true,
|
||||
stripe_subscription_id: true,
|
||||
paypal_subscription_id: true,
|
||||
billing_provider: true,
|
||||
},
|
||||
})
|
||||
|
||||
const params = await searchParams
|
||||
const currentPlan = (profile?.plan ?? "starter") as Plan
|
||||
const hasStripeAccount = !!profile?.stripe_customer_id
|
||||
const isPaypal = profile?.billing_provider === "paypal" || !!profile?.paypal_subscription_id
|
||||
const paypalEnabled = paypalConfigured()
|
||||
const limits = PLAN_LIMITS[currentPlan]
|
||||
const canBillAnnually = annualEnabled()
|
||||
|
||||
@@ -113,12 +107,6 @@ export default async function BillingPage({
|
||||
Checkout canceled — no charge was made.
|
||||
</div>
|
||||
)}
|
||||
{params.error === "paypal" && (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-5 py-4 text-sm text-red-400">
|
||||
We couldn't complete your PayPal payment. No charge was made — please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current plan */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -129,12 +117,9 @@ export default async function BillingPage({
|
||||
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
|
||||
)}
|
||||
</div>
|
||||
{currentPlan !== "starter" && currentPlan !== "lifetime" &&
|
||||
(isPaypal ? (
|
||||
<PaypalCancelButton />
|
||||
) : hasStripeAccount ? (
|
||||
<PortalButton />
|
||||
) : null)}
|
||||
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
|
||||
<PortalButton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,7 +182,6 @@ export default async function BillingPage({
|
||||
label={plan.cta}
|
||||
highlight={plan.highlight}
|
||||
annualAvailable={canBillAnnually && plan.key !== "lifetime"}
|
||||
paypalEnabled={paypalEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { redirect } from "next/navigation"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { listProviders, listConnections } from "@/lib/accounting"
|
||||
import { listEsignAdapters, listEsignConnections } from "@/lib/esign"
|
||||
import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations"
|
||||
import { EsignIntegrations } from "@/components/dashboard/esign-integrations"
|
||||
|
||||
export const metadata = { title: "Integrations" }
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -20,20 +22,45 @@ export default async function IntegrationsPage({
|
||||
const providers = listProviders()
|
||||
const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : []
|
||||
|
||||
const esignAdapters = listEsignAdapters()
|
||||
const esignConnections = ctx.isOwner ? await listEsignConnections(ctx.ownerId) : []
|
||||
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
||||
// DocuSign vs Dropbox Sign flash messages are keyed by provider id, so a single
|
||||
// connected/error param drives whichever card the user just acted on.
|
||||
const esignFlash = { connected: sp.connected, error: sp.error }
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Integrations</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
Connect your accounting software to automatically push rent income and expenses into your books.
|
||||
</p>
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">E-signature</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
Connect your own DocuSign or Dropbox Sign account to send leases for signature.
|
||||
</p>
|
||||
</div>
|
||||
<EsignIntegrations
|
||||
adapters={esignAdapters}
|
||||
connections={esignConnections}
|
||||
isOwner={ctx.isOwner}
|
||||
flash={esignFlash}
|
||||
webhookUrl={`${appUrl}/api/esign/dropbox_sign/webhook`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Accounting</h2>
|
||||
<p className="text-sm text-white/40 mt-0.5">
|
||||
Connect your accounting software to automatically push rent income and expenses into your books.
|
||||
</p>
|
||||
</div>
|
||||
<AccountingIntegrations
|
||||
providers={providers}
|
||||
connections={connections}
|
||||
isOwner={ctx.isOwner}
|
||||
flash={{ connected: sp.connected, error: sp.error }}
|
||||
/>
|
||||
</div>
|
||||
<AccountingIntegrations
|
||||
providers={providers}
|
||||
connections={connections}
|
||||
isOwner={ctx.isOwner}
|
||||
flash={{ connected: sp.connected, error: sp.error }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { z } from "zod"
|
||||
import { getAdminSession } from "@/lib/session"
|
||||
import { logAdminAction } from "@/lib/admin/audit"
|
||||
import { setMaintenanceMode } from "@/lib/settings"
|
||||
import { setAiProvider, type AiProvider } from "@/lib/ai/provider"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles, user as userTable } from "@/lib/db/schema"
|
||||
@@ -158,3 +159,22 @@ export async function setSiteMaintenance(enabled: boolean, message?: string) {
|
||||
revalidatePath("/", "layout")
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── AI provider ───────────────────────────────────────────────────────────────
|
||||
// Chooses which LLM provider powers all AI features (OpenAI or Anthropic/Claude),
|
||||
// persisted in app_settings. Applies immediately to every AI route.
|
||||
export async function setAiProviderAction(provider: string) {
|
||||
const a = await guard()
|
||||
if (provider !== "openai" && provider !== "anthropic") throw new Error("Invalid AI provider")
|
||||
|
||||
await setAiProvider(provider as AiProvider)
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "ai_provider",
|
||||
metadata: { provider },
|
||||
})
|
||||
|
||||
revalidatePath("/admin/system")
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ export async function signUp(formData: FormData) {
|
||||
headers: h,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof APIError ? e.message : "Sign up failed"
|
||||
const raw = e instanceof APIError ? e.message : "Sign up failed"
|
||||
// Don't reveal that an email is already registered (user enumeration) — the
|
||||
// "already exists" path must not be distinguishable from other failures.
|
||||
const msg = /exist|registered|already|taken/i.test(raw)
|
||||
? "We couldn't complete your sign-up. Please try a different email or sign in."
|
||||
: raw
|
||||
redirect(`/signup?error=${encodeURIComponent(msg)}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { leases } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { sendLeaseForSignature, getAdapter, type ESignProvider } from "@/lib/esign"
|
||||
import { keyBelongsToOwner } from "@/lib/storage"
|
||||
import {
|
||||
sendLeaseForSignature,
|
||||
getAdapter,
|
||||
saveEsignConnection,
|
||||
disconnectEsign,
|
||||
type ESignProvider,
|
||||
} from "@/lib/esign"
|
||||
|
||||
async function ownerGuard() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
const ctx = await getAccountContext(user.id)
|
||||
if (!ctx.isOwner) throw new Error("Only the account owner can manage integrations")
|
||||
return ctx
|
||||
}
|
||||
|
||||
export async function sendLeaseForSignatureAction(leaseId: string, provider: string) {
|
||||
const user = await getSessionUser()
|
||||
@@ -15,3 +33,47 @@ export async function sendLeaseForSignatureAction(leaseId: string, provider: str
|
||||
revalidatePath(`/leases/${leaseId}`)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** Connect Dropbox Sign by validating and storing the landlord's API key. */
|
||||
export async function connectDropboxSign(apiKey: string) {
|
||||
const ctx = await ownerGuard()
|
||||
const adapter = getAdapter("dropbox_sign")
|
||||
if (!adapter) throw new Error("Unknown provider")
|
||||
const tokens = await adapter.connectApiKey(typeof apiKey === "string" ? apiKey : "")
|
||||
await saveEsignConnection(ctx.ownerId, "dropbox_sign", tokens)
|
||||
revalidatePath("/settings/integrations")
|
||||
return { ok: true, accountName: tokens.accountName }
|
||||
}
|
||||
|
||||
export async function disconnectEsignAction(provider: string) {
|
||||
const ctx = await ownerGuard()
|
||||
if (!getAdapter(provider)) throw new Error("Unknown provider")
|
||||
await disconnectEsign(ctx.ownerId, provider as ESignProvider)
|
||||
revalidatePath("/settings/integrations")
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an already-uploaded document (via /api/upload) to a lease. Validates
|
||||
* the file belongs to the caller's namespace to prevent cross-tenant refs.
|
||||
*/
|
||||
export async function setLeaseDocument(leaseId: string, fileUrl: string) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) throw new Error("Unauthorized")
|
||||
const ctx = await getAccountContext(user.id)
|
||||
if (!ctx.canWrite) throw new Error("You don't have permission to do that")
|
||||
|
||||
const prefix = "/api/files/"
|
||||
if (typeof fileUrl !== "string" || !fileUrl.startsWith(prefix)) throw new Error("Invalid document reference")
|
||||
if (!keyBelongsToOwner(fileUrl.slice(prefix.length), ctx.ownerId)) throw new Error("Invalid document reference")
|
||||
|
||||
const [row] = await db
|
||||
.update(leases)
|
||||
.set({ document_url: fileUrl })
|
||||
.where(and(eq(leases.id, leaseId), eq(leases.user_id, ctx.ownerId)))
|
||||
.returning({ id: leases.id })
|
||||
if (!row) throw new Error("Lease not found")
|
||||
|
||||
revalidatePath(`/leases/${leaseId}`)
|
||||
return { ok: true, url: fileUrl }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getEffectiveOwnerId } from "@/lib/account"
|
||||
import { openai } from "@/lib/ai/client"
|
||||
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
|
||||
import { aiComplete } from "@/lib/ai/provider"
|
||||
import { enforceAiQuota } from "@/lib/ai/usage"
|
||||
import { dataBlock } from "@/lib/ai/prompts"
|
||||
|
||||
@@ -21,6 +22,9 @@ export async function POST(request: Request) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Before the quota check so an unconfigured server never burns a call.
|
||||
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
|
||||
|
||||
const quota = await enforceAiQuota(user.id, "ai_ask")
|
||||
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
|
||||
|
||||
@@ -154,16 +158,22 @@ ${dataBlock("EXPIRING LEASES", JSON.stringify(expiringLeases, null, 2))}
|
||||
Answer the landlord's question in a helpful, concise, and professional manner. Use bullet points where appropriate. Be specific with numbers from the data above. If the question is unrelated to property management, politely redirect.
|
||||
`
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{ role: "system", content: context },
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
})
|
||||
|
||||
const answer = completion.choices[0].message.content ?? ""
|
||||
let answer: string
|
||||
try {
|
||||
answer = await aiComplete({
|
||||
messages: [
|
||||
{ role: "system", content: context },
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
maxTokens: 1024,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("[ai/ask] AI request failed:", err)
|
||||
return NextResponse.json(
|
||||
{ error: "The AI service is temporarily unavailable. Please try again in a moment." },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { db } from "@/lib/db"
|
||||
import { properties, maintenance_requests } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getEffectiveOwnerId } from "@/lib/account"
|
||||
import { openai } from "@/lib/ai/client"
|
||||
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
|
||||
import { aiComplete } from "@/lib/ai/provider"
|
||||
import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts"
|
||||
import { enforceAiQuota } from "@/lib/ai/usage"
|
||||
|
||||
@@ -12,6 +13,9 @@ export async function POST(request: Request) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Before the quota check so an unconfigured server never burns a call.
|
||||
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
|
||||
|
||||
const quota = await enforceAiQuota(user.id, "ai_maintenance_summary")
|
||||
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
|
||||
|
||||
@@ -39,9 +43,7 @@ export async function POST(request: Request) {
|
||||
columns: { name: true },
|
||||
})
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
max_tokens: 1024,
|
||||
const text = await aiComplete({
|
||||
messages: [
|
||||
{ role: "system", content: MAINTENANCE_SUMMARY_PROMPT },
|
||||
{
|
||||
@@ -49,13 +51,13 @@ export async function POST(request: Request) {
|
||||
content: `${dataBlock("PROPERTY NAME", property?.name ?? "Unknown")}\n\n${dataBlock("MAINTENANCE REQUESTS", JSON.stringify(requests, null, 2))}`,
|
||||
},
|
||||
],
|
||||
maxTokens: 1024,
|
||||
json: true,
|
||||
})
|
||||
|
||||
const text = completion.choices[0].message.content ?? ""
|
||||
|
||||
let summary
|
||||
try {
|
||||
summary = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim())
|
||||
summary = JSON.parse(text)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
|
||||
import { openai } from "@/lib/ai/client"
|
||||
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
|
||||
import { aiComplete } from "@/lib/ai/provider"
|
||||
import { logActivity } from "@/lib/activity"
|
||||
import { enforceAiQuota } from "@/lib/ai/usage"
|
||||
import { dataBlock } from "@/lib/ai/prompts"
|
||||
@@ -38,6 +39,9 @@ export async function POST() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Before the quota check so an unconfigured server never burns a call.
|
||||
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
|
||||
|
||||
const quota = await enforceAiQuota(user.id, "ai_predictions")
|
||||
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
|
||||
|
||||
@@ -174,16 +178,15 @@ Generate a JSON object with key "predictions" containing an array of 5-7 predict
|
||||
|
||||
Only return valid JSON, no other text.`
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
max_tokens: 2000,
|
||||
const content = await aiComplete({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
response_format: { type: "json_object" },
|
||||
maxTokens: 2000,
|
||||
json: true,
|
||||
})
|
||||
|
||||
let predictions: any[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}")
|
||||
const parsed = JSON.parse(content || "{}")
|
||||
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
|
||||
import { openai } from "@/lib/ai/client"
|
||||
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
|
||||
import { aiComplete } from "@/lib/ai/provider"
|
||||
import { logActivity } from "@/lib/activity"
|
||||
import { enforceAiQuota } from "@/lib/ai/usage"
|
||||
import { dataBlock } from "@/lib/ai/prompts"
|
||||
@@ -37,6 +38,9 @@ export async function POST() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Before the quota check so an unconfigured server never burns a call.
|
||||
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
|
||||
|
||||
const quota = await enforceAiQuota(user.id, "ai_recommendations")
|
||||
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
|
||||
|
||||
@@ -167,13 +171,12 @@ Only return valid JSON, no other text.`
|
||||
|
||||
let recommendations: any[] = []
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
max_tokens: 1500,
|
||||
const content = await aiComplete({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
response_format: { type: "json_object" },
|
||||
maxTokens: 1500,
|
||||
json: true,
|
||||
})
|
||||
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}")
|
||||
const parsed = JSON.parse(content || "{}")
|
||||
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { openai } from "@/lib/ai/client"
|
||||
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
|
||||
import { aiComplete } from "@/lib/ai/provider"
|
||||
import { RENT_RECEIPT_PROMPT, dataBlock } from "@/lib/ai/prompts"
|
||||
import { enforceAiQuota } from "@/lib/ai/usage"
|
||||
|
||||
@@ -25,6 +26,9 @@ export async function POST(request: Request) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Before the quota check so an unconfigured server never burns a call.
|
||||
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
|
||||
|
||||
const quota = await enforceAiQuota(user.id, "ai_rent_receipt")
|
||||
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
|
||||
|
||||
@@ -36,9 +40,7 @@ export async function POST(request: Request) {
|
||||
// Pass only the whitelisted, validated fields to the model.
|
||||
const { payment_id, ...receiptFields } = parsed.data
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
max_tokens: 1024,
|
||||
const text = await aiComplete({
|
||||
messages: [
|
||||
{ role: "system", content: RENT_RECEIPT_PROMPT },
|
||||
{
|
||||
@@ -46,13 +48,13 @@ export async function POST(request: Request) {
|
||||
content: dataBlock("PAYMENT DETAILS", JSON.stringify(receiptFields, null, 2)),
|
||||
},
|
||||
],
|
||||
maxTokens: 1024,
|
||||
json: true,
|
||||
})
|
||||
|
||||
const text = completion.choices[0].message.content ?? ""
|
||||
|
||||
let receipt
|
||||
try {
|
||||
receipt = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim())
|
||||
receipt = JSON.parse(text)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
||||
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
|
||||
|
||||
if (doc.storage_path) {
|
||||
await deleteFile(doc.storage_path)
|
||||
await deleteFile(doc.storage_path, ownerId)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
@@ -3,7 +3,14 @@ import { and, desc, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { documents, properties } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
|
||||
import {
|
||||
saveFile,
|
||||
isAllowedUploadExt,
|
||||
StorageNotConfiguredError,
|
||||
keyBelongsToOwner,
|
||||
contentMatchesExtension,
|
||||
extOf,
|
||||
} from "@/lib/storage"
|
||||
import { checkStorageLimit } from "@/lib/plan-limits"
|
||||
import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
|
||||
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
|
||||
@@ -57,6 +64,10 @@ export async function POST(request: Request) {
|
||||
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 })
|
||||
if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
|
||||
if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
|
||||
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
|
||||
if (!contentMatchesExtension(head, extOf(file.name))) {
|
||||
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
|
||||
}
|
||||
|
||||
const storageError = await checkStorageLimit(ownerId, file.size)
|
||||
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
|
||||
@@ -113,6 +124,20 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Tenant not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
// The file reference is client-supplied. Require it to be an /api/files URL
|
||||
// inside the caller's OWN namespace, and derive storage_path from it — never
|
||||
// trust a separate client storage_path (which could point at another tenant's
|
||||
// object and later be deleted). Also blocks javascript:/external file_url values.
|
||||
const FILES_PREFIX = "/api/files/"
|
||||
const fileUrl = typeof body.file_url === "string" ? body.file_url : ""
|
||||
if (!fileUrl.startsWith(FILES_PREFIX)) {
|
||||
return NextResponse.json({ error: "file_url must reference an uploaded file" }, { status: 400 })
|
||||
}
|
||||
const storagePath = fileUrl.slice(FILES_PREFIX.length)
|
||||
if (!keyBelongsToOwner(storagePath, ownerId)) {
|
||||
return NextResponse.json({ error: "Invalid file reference" }, { status: 403 })
|
||||
}
|
||||
|
||||
// Whitelist insertable columns — never trust client-supplied user_id/id/created_at.
|
||||
const [data] = await db
|
||||
.insert(documents)
|
||||
@@ -122,8 +147,8 @@ export async function POST(request: Request) {
|
||||
tenant_id: tenantId,
|
||||
name: body.name as string,
|
||||
category: (body.category as typeof documents.$inferInsert.category) ?? "other",
|
||||
file_url: body.file_url as string,
|
||||
storage_path: body.storage_path as string | undefined,
|
||||
file_url: fileUrl,
|
||||
storage_path: storagePath,
|
||||
file_type: body.file_type as string | undefined,
|
||||
file_size: body.file_size as number | undefined,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { getAdapter, saveEsignConnection, type ESignProvider } from "@/lib/esign"
|
||||
import { verifyState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state"
|
||||
|
||||
// OAuth callback — exchanges the code for tokens and stores the connection.
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
|
||||
const { provider } = await params
|
||||
const adapter = getAdapter(provider)
|
||||
const settings = new URL("/settings/integrations", request.url)
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const nonceCookie = cookieStore.get(ESIGN_NONCE_COOKIE)?.value
|
||||
const done = (p: Record<string, string>) => {
|
||||
for (const [k, v] of Object.entries(p)) settings.searchParams.set(k, v)
|
||||
const res = NextResponse.redirect(settings)
|
||||
res.cookies.set(ESIGN_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
|
||||
return res
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const oauthError = url.searchParams.get("error")
|
||||
|
||||
if (oauthError || !adapter || adapter.kind !== "oauth") return done({ error: "connect_failed" })
|
||||
|
||||
const st = state ? verifyState(state) : null
|
||||
// CSRF: state nonce must match the cookie, and the session must be the same owner.
|
||||
if (!code || !st || st.provider !== provider || !nonceCookie || nonceCookie !== st.nonce) {
|
||||
return done({ error: "invalid_state" })
|
||||
}
|
||||
const user = await getSessionUser()
|
||||
if (!user) return done({ error: "invalid_state" })
|
||||
const ctx = await getAccountContext(user.id)
|
||||
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
|
||||
|
||||
try {
|
||||
const tokens = await adapter.exchangeCode(code)
|
||||
if (!tokens.accountId || !tokens.baseUri) throw new Error("No account returned from provider")
|
||||
await saveEsignConnection(st.ownerId, provider as ESignProvider, tokens)
|
||||
return done({ connected: provider })
|
||||
} catch {
|
||||
return done({ error: "connect_failed" })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getEffectiveOwnerId } from "@/lib/account"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { runFollowUpsForUser } from "@/lib/follow-ups"
|
||||
|
||||
export async function POST() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const ownerId = await getEffectiveOwnerId(user.id)
|
||||
// Sends real outbound follow-ups — a mutating action, so viewers are blocked.
|
||||
const ctx = await getAccountContext(user.id)
|
||||
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
|
||||
const result = await runFollowUpsForUser(ownerId)
|
||||
const result = await runFollowUpsForUser(ctx.ownerId)
|
||||
|
||||
// Preserve the original response shape ({ sent, results }). The detailed
|
||||
// per-follow-up rows now live only in follow_up_log; the client re-fetches
|
||||
|
||||
@@ -1,37 +1,51 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { getProvider, saveConnection, type Provider } from "@/lib/accounting"
|
||||
import { verifyState } from "@/lib/accounting/state"
|
||||
import { verifyState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
|
||||
|
||||
// OAuth callback — exchanges the code for tokens and stores the connection.
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
|
||||
const { provider: pid } = await params
|
||||
const prov = getProvider(pid)
|
||||
const url = new URL(request.url)
|
||||
const settings = new URL("/settings/integrations", request.url)
|
||||
|
||||
// Always clear the one-shot nonce cookie on the way out.
|
||||
const cookieStore = await cookies()
|
||||
const nonceCookie = cookieStore.get(OAUTH_NONCE_COOKIE)?.value
|
||||
const done = (params: Record<string, string>) => {
|
||||
for (const [k, v] of Object.entries(params)) settings.searchParams.set(k, v)
|
||||
const res = NextResponse.redirect(settings)
|
||||
res.cookies.set(OAUTH_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
|
||||
return res
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const realmId = url.searchParams.get("realmId") // QuickBooks includes this
|
||||
const oauthError = url.searchParams.get("error")
|
||||
|
||||
if (oauthError || !prov) {
|
||||
settings.searchParams.set("error", "connect_failed")
|
||||
return NextResponse.redirect(settings)
|
||||
}
|
||||
if (oauthError || !prov) return done({ error: "connect_failed" })
|
||||
|
||||
const st = state ? verifyState(state) : null
|
||||
if (!code || !st || st.provider !== pid) {
|
||||
settings.searchParams.set("error", "invalid_state")
|
||||
return NextResponse.redirect(settings)
|
||||
// CSRF: the state's nonce must match the cookie set at connect time, and the
|
||||
// current session must be the same owner that initiated the connect.
|
||||
if (!code || !st || st.provider !== pid || !nonceCookie || nonceCookie !== st.nonce) {
|
||||
return done({ error: "invalid_state" })
|
||||
}
|
||||
const user = await getSessionUser()
|
||||
if (!user) return done({ error: "invalid_state" })
|
||||
const ctx = await getAccountContext(user.id)
|
||||
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
|
||||
|
||||
try {
|
||||
const tokens = await prov.exchangeCode(code, realmId)
|
||||
if (!tokens.realmId) throw new Error("No organisation returned from provider")
|
||||
await saveConnection(st.ownerId, pid as Provider, tokens)
|
||||
settings.searchParams.set("connected", pid)
|
||||
return done({ connected: pid })
|
||||
} catch {
|
||||
settings.searchParams.set("error", "connect_failed")
|
||||
return done({ error: "connect_failed" })
|
||||
}
|
||||
return NextResponse.redirect(settings)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import crypto from "crypto"
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { getProvider } from "@/lib/accounting"
|
||||
import { signState } from "@/lib/accounting/state"
|
||||
import { signState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
|
||||
|
||||
// Starts the OAuth connect flow for an accounting provider (owner-only).
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
|
||||
@@ -28,6 +29,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov
|
||||
return NextResponse.redirect(settings)
|
||||
}
|
||||
|
||||
const state = signState({ ownerId: ctx.ownerId, provider: pid })
|
||||
return NextResponse.redirect(prov.getAuthUrl(state))
|
||||
// Bind the OAuth round-trip to this browser: a random nonce goes into the
|
||||
// signed state AND an httpOnly cookie; the callback requires them to match.
|
||||
const nonce = crypto.randomUUID()
|
||||
const state = signState({ ownerId: ctx.ownerId, provider: pid, nonce })
|
||||
const res = NextResponse.redirect(prov.getAuthUrl(state))
|
||||
res.cookies.set(OAUTH_NONCE_COOKIE, nonce, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -8,8 +8,17 @@ export async function GET() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Exclude bearer/secret + billing-id columns from the client payload. The
|
||||
// calendar feed token and Stripe/PayPal ids are used server-side only.
|
||||
const profile = await db.query.profiles.findFirst({
|
||||
where: eq(profiles.id, user.id),
|
||||
columns: {
|
||||
calendar_token: false,
|
||||
stripe_customer_id: false,
|
||||
stripe_subscription_id: false,
|
||||
paypal_subscription_id: false,
|
||||
billing_provider: false,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ profile: profile ?? null })
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getAccountContext } from "@/lib/account"
|
||||
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
|
||||
import {
|
||||
saveFile,
|
||||
isAllowedUploadExt,
|
||||
StorageNotConfiguredError,
|
||||
contentMatchesExtension,
|
||||
extOf,
|
||||
} from "@/lib/storage"
|
||||
import { checkStorageLimit } from "@/lib/plan-limits"
|
||||
|
||||
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
|
||||
@@ -33,6 +39,12 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Reject files whose real content doesn't match the claimed extension.
|
||||
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
|
||||
if (!contentMatchesExtension(head, extOf(file.name))) {
|
||||
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Enforce per-plan storage quota (accounts for everything already stored in
|
||||
// the owner's portfolio namespace).
|
||||
const storageError = await checkStorageLimit(ownerId, file.size)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function GlobalError({
|
||||
@@ -10,6 +11,7 @@ export default function GlobalError({
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error)
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
|
||||
@@ -12,11 +12,23 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
theme_color: "#09090b",
|
||||
icons: [
|
||||
{
|
||||
src: "/logo-mark.png",
|
||||
src: "/icon-192.png",
|
||||
type: "image/png",
|
||||
sizes: "100x100",
|
||||
sizes: "192x192",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
type: "image/png",
|
||||
sizes: "512x512",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icon-maskable-512.png",
|
||||
type: "image/png",
|
||||
sizes: "512x512",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
})
|
||||
}
|
||||
@@ -1,17 +1,38 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { LEGAL_PAGES } from "@/lib/legal"
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
|
||||
|
||||
type ChangeFrequency = MetadataRoute.Sitemap[number]["changeFrequency"]
|
||||
|
||||
// Single source of truth for the public, indexable URL surface. Every entry
|
||||
// must resolve to a real 200 page that is NOT noindex'd. Private/app routes are
|
||||
// blocked in app/robots.ts, and the /login and /forgot-password auth pages are
|
||||
// noindex, so all three are intentionally omitted here. /signup is kept as a
|
||||
// conversion landing page.
|
||||
const PAGES: { path: string; changeFrequency: ChangeFrequency; priority: number }[] = [
|
||||
{ path: "/", changeFrequency: "weekly", priority: 1 },
|
||||
{ path: "/tenant-portal-info", changeFrequency: "monthly", priority: 0.8 },
|
||||
{ path: "/api-docs", changeFrequency: "monthly", priority: 0.8 },
|
||||
{ path: "/signup", changeFrequency: "monthly", priority: 0.7 },
|
||||
// Legal pages are derived from the shared LEGAL_PAGES constant (the same list
|
||||
// the footer renders) so the sitemap can never drift from the real routes.
|
||||
...LEGAL_PAGES.map((page) => ({
|
||||
path: page.href,
|
||||
changeFrequency: "yearly" as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
]
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
|
||||
const lastModified = new Date("2026-07-01")
|
||||
// Build-time timestamp, refreshed on every deploy. We don't track per-page
|
||||
// modification dates, so a single honest "last built" date is used throughout.
|
||||
const lastModified = new Date()
|
||||
|
||||
return [
|
||||
{ url: base, lastModified, changeFrequency: "weekly", priority: 1 },
|
||||
{ url: `${base}/tenant-portal-info`, lastModified, changeFrequency: "monthly", priority: 0.8 },
|
||||
{ url: `${base}/api-docs`, lastModified, changeFrequency: "monthly", priority: 0.6 },
|
||||
{ url: `${base}/signup`, lastModified, changeFrequency: "monthly", priority: 0.7 },
|
||||
{ url: `${base}/privacy`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${base}/terms`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${base}/cookie-policy`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
{ url: `${base}/gdpr`, lastModified, changeFrequency: "yearly", priority: 0.3 },
|
||||
]
|
||||
return PAGES.map(({ path, changeFrequency, priority }) => ({
|
||||
url: path === "/" ? base : `${base}${path}`,
|
||||
lastModified,
|
||||
changeFrequency,
|
||||
priority,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export function CheckoutButton({
|
||||
highlight,
|
||||
interval = "month",
|
||||
annualAvailable = false,
|
||||
paypalEnabled = false,
|
||||
}: {
|
||||
plan: string
|
||||
label: string
|
||||
@@ -18,11 +17,8 @@ export function CheckoutButton({
|
||||
// When true, show a monthly/annual choice. Only pass this for subscription
|
||||
// plans and only when annual billing is actually configured server-side.
|
||||
annualAvailable?: boolean
|
||||
// When true, also offer "Pay with PayPal" using the same interval choice.
|
||||
paypalEnabled?: boolean
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [paypalLoading, setPaypalLoading] = useState(false)
|
||||
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
|
||||
|
||||
const effectiveInterval = annualAvailable ? chosenInterval : interval
|
||||
@@ -39,21 +35,6 @@ export function CheckoutButton({
|
||||
else setLoading(false)
|
||||
}
|
||||
|
||||
async function handlePaypal() {
|
||||
setPaypalLoading(true)
|
||||
const res = await fetch("/api/paypal/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan, interval: effectiveInterval }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
else {
|
||||
setPaypalLoading(false)
|
||||
if (data.error) alert(data.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{annualAvailable && (
|
||||
@@ -82,7 +63,7 @@ export function CheckoutButton({
|
||||
)}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading || paypalLoading}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
|
||||
highlight
|
||||
@@ -92,22 +73,6 @@ export function CheckoutButton({
|
||||
>
|
||||
{loading ? "Loading..." : label}
|
||||
</button>
|
||||
|
||||
{paypalEnabled && (
|
||||
<button
|
||||
onClick={handlePaypal}
|
||||
disabled={loading || paypalLoading}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-[#ffc439] py-2 text-xs font-bold text-[#003087] transition hover:bg-[#f0b90b] disabled:opacity-50"
|
||||
>
|
||||
{paypalLoading ? (
|
||||
"Loading..."
|
||||
) : (
|
||||
<>
|
||||
Pay with <span className="font-extrabold italic">Pay<span className="text-[#009cde]">Pal</span></span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useTransition } from "react"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
|
||||
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle, Download } from "lucide-react"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
|
||||
|
||||
@@ -12,11 +13,12 @@ type Req = {
|
||||
status: string
|
||||
signer_email: string
|
||||
document_name: string | null
|
||||
signed_document_url: string | null
|
||||
sent_at: string | null
|
||||
completed_at: string | null
|
||||
last_error: string | null
|
||||
}
|
||||
type Prov = { id: string; label: string; configured: boolean }
|
||||
type Prov = { id: string; label: string }
|
||||
|
||||
const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = {
|
||||
sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock },
|
||||
@@ -29,18 +31,17 @@ const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_s
|
||||
|
||||
export function EsignLease({
|
||||
leaseId,
|
||||
providers,
|
||||
connected,
|
||||
requests,
|
||||
canSend,
|
||||
disabledReason,
|
||||
}: {
|
||||
leaseId: string
|
||||
providers: Prov[]
|
||||
connected: Prov[]
|
||||
requests: Req[]
|
||||
canSend: boolean
|
||||
disabledReason: string
|
||||
}) {
|
||||
const configured = providers.filter((p) => p.configured)
|
||||
const [pending, start] = useTransition()
|
||||
|
||||
function send(provider: string) {
|
||||
@@ -74,6 +75,16 @@ export function EsignLease({
|
||||
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
|
||||
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
|
||||
</p>
|
||||
{r.signed_document_url && (
|
||||
<a
|
||||
href={r.signed_document_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-[11px] text-indigo-400 transition hover:text-indigo-300"
|
||||
>
|
||||
<Download className="h-3 w-3" /> Signed document
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}>
|
||||
<s.Icon className="h-3 w-3" /> {s.label}
|
||||
@@ -84,11 +95,16 @@ export function EsignLease({
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{configured.length === 0 ? (
|
||||
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p>
|
||||
{connected.length === 0 ? (
|
||||
<p className="text-xs text-white/30">
|
||||
<Link href="/settings/integrations" className="text-indigo-400 hover:text-indigo-300">
|
||||
Connect DocuSign or Dropbox Sign
|
||||
</Link>{" "}
|
||||
in Settings → Integrations to send leases for signature.
|
||||
</p>
|
||||
) : canSend ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{configured.map((p) => (
|
||||
{connected.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => send(p.id)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
|
||||
import type { Plan } from "@/types"
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
|
||||
|
||||
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
|
||||
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
|
||||
// we advertise only the monthly / one-time base prices that actually exist.
|
||||
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
|
||||
const planOffers = planOrder.map((plan) => ({
|
||||
"@type": "Offer",
|
||||
name: getPlanLabel(plan),
|
||||
price: String(PLAN_AMOUNTS[plan]),
|
||||
priceCurrency: "USD",
|
||||
}))
|
||||
|
||||
// Mirrors the visible FAQ content in components/marketing/faq.tsx.
|
||||
// Keep these in sync with that source so the JSON-LD matches what users see.
|
||||
const faqs = [
|
||||
@@ -43,6 +57,11 @@ const organization: Record<string, unknown> = {
|
||||
name: "Property Management Network",
|
||||
url: base,
|
||||
logo: `${base}/logo-mark.png`,
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
contactType: "customer support",
|
||||
email: "support@propertymanagement.network",
|
||||
},
|
||||
sameAs: [
|
||||
"https://twitter.com/propertymgmtnet",
|
||||
"https://github.com/propertymanagement-network",
|
||||
@@ -64,11 +83,7 @@ const softwareApplication: Record<string, unknown> = {
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
offers: planOffers,
|
||||
}
|
||||
|
||||
const faqPage: Record<string, unknown> = {
|
||||
|
||||
@@ -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
|
||||
@@ -1,8 +1,19 @@
|
||||
// Next.js instrumentation hook — runs once when the server process starts.
|
||||
// Surfaces "silently disabled" integrations at boot so a misconfigured deploy is
|
||||
// obvious in the logs instead of failing quietly at runtime.
|
||||
export function register() {
|
||||
// Only run in the Node.js server runtime (not edge/middleware) and only in prod.
|
||||
// (1) Initializes Sentry for the active runtime, and (2) surfaces "silently
|
||||
// disabled" integrations at boot so a misconfigured deploy is obvious in the
|
||||
// logs instead of failing quietly at runtime.
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
|
||||
export async function register() {
|
||||
// Initialize Sentry for whichever server runtime is booting.
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config")
|
||||
}
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config")
|
||||
}
|
||||
|
||||
// --- Startup env warnings (Node.js server runtime, production only) ---
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return
|
||||
if (process.env.NODE_ENV !== "production") return
|
||||
|
||||
@@ -33,4 +44,12 @@ export function register() {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
warn("OPENAI_API_KEY is NOT set — AI features will error when called.")
|
||||
}
|
||||
|
||||
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
warn("SENTRY DSN is NOT set — error monitoring is disabled (no crash reports).")
|
||||
}
|
||||
}
|
||||
|
||||
// Capture errors thrown in nested React Server Components, route handlers, and
|
||||
// server actions and report them to Sentry.
|
||||
export const onRequestError = Sentry.captureRequestError
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider
|
||||
// and is tamper-proof, so the callback can't be forged/CSRF'd.
|
||||
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret"
|
||||
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider,
|
||||
// a random nonce (bound to a cookie by the connect route for CSRF protection),
|
||||
// and an issued-at timestamp so a leaked state can't be replayed indefinitely.
|
||||
|
||||
export function signState(data: { ownerId: string; provider: string }): string {
|
||||
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
|
||||
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
|
||||
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
// Short-lived httpOnly cookie the connect route sets and the callback verifies
|
||||
// against the state's nonce (binds the OAuth round-trip to the initiating browser).
|
||||
export const OAUTH_NONCE_COOKIE = "acct_oauth_nonce"
|
||||
|
||||
// No insecure fallback: signing/verifying state without the real secret would
|
||||
// let anyone forge a state for any owner, so we fail closed (mirrors lib/crypto.ts).
|
||||
function secret(): string {
|
||||
const s = process.env.BETTER_AUTH_SECRET
|
||||
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
|
||||
return s
|
||||
}
|
||||
|
||||
export type OAuthState = { ownerId: string; provider: string; nonce: string }
|
||||
|
||||
export function signState(data: OAuthState): string {
|
||||
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
|
||||
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
|
||||
return `${payload}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyState(state: string): { ownerId: string; provider: string } | null {
|
||||
export function verifyState(state: string): OAuthState | null {
|
||||
const [payload, sig] = state.split(".")
|
||||
if (!payload || !sig) return null
|
||||
const expect = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
|
||||
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
|
||||
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
|
||||
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as OAuthState & { iat?: number }
|
||||
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
|
||||
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
|
||||
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type AdminAction =
|
||||
| "delete_user"
|
||||
| "resend_verification"
|
||||
| "maintenance_mode"
|
||||
| "ai_provider"
|
||||
|
||||
/**
|
||||
* Append one immutable row to admin_audit_log. Call this for EVERY mutating
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
import OpenAI from "openai"
|
||||
|
||||
// True when the server has an AI provider key (OpenAI OR Anthropic). AI routes
|
||||
// check this up front and return a friendly 503 instead of throwing, matching
|
||||
// how the other optional integrations (Stripe / SMTP / Turnstile) degrade when
|
||||
// unconfigured. The active provider is chosen by an admin (see lib/ai/provider).
|
||||
export function aiConfigured(): boolean {
|
||||
return Boolean(process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY)
|
||||
}
|
||||
|
||||
export const AI_UNCONFIGURED_ERROR =
|
||||
"AI features aren't configured on this server (no OpenAI or Anthropic API key). Ask your administrator to enable them."
|
||||
|
||||
// Lazily construct the OpenAI client so `next build` does NOT require
|
||||
// OPENAI_API_KEY — it's only needed at runtime. Call sites keep using
|
||||
// `openai.xxx` unchanged; the Proxy builds the real client on first access.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -254,6 +254,7 @@ export function getEnvHealth() {
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"SMTP_HOST",
|
||||
"SMTP_USER",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE "esign_connections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"access_token" text NOT NULL,
|
||||
"refresh_token" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"account_id" text,
|
||||
"base_uri" text,
|
||||
"account_name" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "esign_connections" ADD CONSTRAINT "esign_connections_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1782994066547,
|
||||
"tag": "0009_amusing_blackheart",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1783017593260,
|
||||
"tag": "0010_esign_connections",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -631,6 +631,32 @@ export const signature_requests = pgTable("signature_requests", {
|
||||
updated_at: updatedAt(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// E-SIGN CONNECTIONS (per-landlord DocuSign OAuth / Dropbox Sign API key)
|
||||
// ============================================================
|
||||
// One row per (owner, provider). Each landlord connects THEIR OWN e-signature
|
||||
// account, so leases are sent from their brand with their audit trail. DocuSign
|
||||
// uses OAuth (access + refresh tokens); Dropbox Sign uses an API key stored in
|
||||
// `access_token`. All secrets are AES-256-GCM encrypted (see lib/crypto.ts).
|
||||
export const esign_connections = pgTable("esign_connections", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => profiles.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").$type<"docusign" | "dropbox_sign">().notNull(),
|
||||
access_token: text("access_token").notNull(), // encrypted (DocuSign access token / Dropbox Sign API key)
|
||||
refresh_token: text("refresh_token"), // encrypted (DocuSign only)
|
||||
expires_at: tstz("expires_at"),
|
||||
// DocuSign account id + base uri from /oauth/userinfo (null for Dropbox Sign).
|
||||
account_id: text("account_id"),
|
||||
base_uri: text("base_uri"),
|
||||
account_name: text("account_name"),
|
||||
status: text("status").$type<"active" | "error" | "revoked">().notNull().default("active"),
|
||||
last_error: text("last_error"),
|
||||
created_at: createdAt(),
|
||||
updated_at: updatedAt(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// WEBHOOK ENDPOINTS (outbound webhooks / Zapier integration)
|
||||
// ============================================================
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -1,44 +1,139 @@
|
||||
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
|
||||
import type { ESignAdapter, ESignCredentials, ESignTokens, SendParams } from "./types"
|
||||
import { esignRedirectUri } from "./types"
|
||||
|
||||
// DocuSign eSignature REST API. Uses a pre-obtained access token (via JWT grant
|
||||
// or OAuth) — set DOCUSIGN_ACCESS_TOKEN / DOCUSIGN_ACCOUNT_ID / DOCUSIGN_BASE_URI.
|
||||
// Docs: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
|
||||
const ACCESS_TOKEN = process.env.DOCUSIGN_ACCESS_TOKEN ?? ""
|
||||
const ACCOUNT_ID = process.env.DOCUSIGN_ACCOUNT_ID ?? ""
|
||||
const BASE_URI = (process.env.DOCUSIGN_BASE_URI ?? "https://demo.docusign.net").replace(/\/+$/, "")
|
||||
// DocuSign eSignature via per-landlord OAuth (Authorization Code Grant).
|
||||
// The OPERATOR registers one DocuSign app and sets these; each LANDLORD then
|
||||
// connects their own DocuSign account through it.
|
||||
// DOCUSIGN_CLIENT_ID / DOCUSIGN_CLIENT_SECRET — the app's integration key + secret
|
||||
// DOCUSIGN_OAUTH_BASE — "account-d.docusign.com" (demo) or "account.docusign.com" (prod)
|
||||
const CLIENT_ID = process.env.DOCUSIGN_CLIENT_ID ?? ""
|
||||
const CLIENT_SECRET = process.env.DOCUSIGN_CLIENT_SECRET ?? ""
|
||||
const OAUTH_BASE = (process.env.DOCUSIGN_OAUTH_BASE ?? "account-d.docusign.com").replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||
|
||||
function basicAuth() {
|
||||
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
|
||||
}
|
||||
|
||||
function extOf(name: string): string {
|
||||
const e = name.split(".").pop()?.toLowerCase()
|
||||
return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf"
|
||||
}
|
||||
|
||||
/** Map a DocuSign envelope/event status string to one of our terminal statuses. */
|
||||
function mapStatus(raw: string): "signed" | "declined" | "voided" | null {
|
||||
const s = raw.toLowerCase()
|
||||
if (s.includes("completed") || s.includes("signed")) return "signed"
|
||||
if (s.includes("declined")) return "declined"
|
||||
if (s.includes("voided")) return "voided"
|
||||
return null
|
||||
}
|
||||
|
||||
async function tokenRequest(form: Record<string, string>): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
|
||||
const res = await fetch(`https://${OAUTH_BASE}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams(form),
|
||||
})
|
||||
if (!res.ok) throw new Error(`DocuSign token error ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function userInfo(accessToken: string): Promise<{ accountId: string | null; baseUri: string | null; accountName: string | null }> {
|
||||
const res = await fetch(`https://${OAUTH_BASE}/oauth/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
|
||||
})
|
||||
if (!res.ok) throw new Error(`DocuSign userinfo ${res.status}`)
|
||||
const j = (await res.json()) as { accounts?: { account_id: string; base_uri: string; account_name: string; is_default: boolean }[] }
|
||||
const acct = j.accounts?.find((a) => a.is_default) ?? j.accounts?.[0]
|
||||
return { accountId: acct?.account_id ?? null, baseUri: acct?.base_uri ?? null, accountName: acct?.account_name ?? null }
|
||||
}
|
||||
|
||||
/** REST API base for the envelopes API, e.g. https://na3.docusign.net/restapi/v2.1/accounts/<id> */
|
||||
function apiBase(creds: ESignCredentials): string {
|
||||
return `${(creds.baseUri ?? "").replace(/\/+$/, "")}/restapi/v2.1/accounts/${creds.accountId}`
|
||||
}
|
||||
|
||||
export const docusign: ESignAdapter = {
|
||||
id: "docusign",
|
||||
label: "DocuSign",
|
||||
configured: () => Boolean(ACCESS_TOKEN && ACCOUNT_ID),
|
||||
kind: "oauth",
|
||||
available: () => Boolean(CLIENT_ID && CLIENT_SECRET),
|
||||
|
||||
async send({ document, documentName, signerEmail, signerName, subject }: SendParams) {
|
||||
getAuthUrl(state) {
|
||||
const p = new URLSearchParams({
|
||||
response_type: "code",
|
||||
// `extended` is required to receive a refresh token.
|
||||
scope: "signature extended",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: esignRedirectUri("docusign"),
|
||||
state,
|
||||
})
|
||||
return `https://${OAUTH_BASE}/oauth/auth?${p.toString()}`
|
||||
},
|
||||
|
||||
async exchangeCode(code): Promise<ESignTokens> {
|
||||
const t = await tokenRequest({ grant_type: "authorization_code", code })
|
||||
const info = await userInfo(t.access_token)
|
||||
return {
|
||||
accessToken: t.access_token,
|
||||
refreshToken: t.refresh_token,
|
||||
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
|
||||
accountId: info.accountId,
|
||||
baseUri: info.baseUri,
|
||||
accountName: info.accountName,
|
||||
}
|
||||
},
|
||||
|
||||
async refresh(refreshToken): Promise<ESignTokens> {
|
||||
const t = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken })
|
||||
// Account id / base uri are stable across refreshes; the resolver re-attaches them.
|
||||
return {
|
||||
accessToken: t.access_token,
|
||||
refreshToken: t.refresh_token,
|
||||
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
|
||||
accountId: null,
|
||||
baseUri: null,
|
||||
accountName: null,
|
||||
}
|
||||
},
|
||||
|
||||
async connectApiKey(): Promise<ESignTokens> {
|
||||
throw new Error("DocuSign connects via OAuth, not an API key")
|
||||
},
|
||||
|
||||
async send(creds, p: SendParams) {
|
||||
const envelope = {
|
||||
emailSubject: subject,
|
||||
emailSubject: p.subject,
|
||||
status: "sent",
|
||||
documents: [{ documentBase64: document.toString("base64"), name: documentName, fileExtension: extOf(documentName), documentId: "1" }],
|
||||
documents: [{ documentBase64: p.document.toString("base64"), name: p.documentName, fileExtension: extOf(p.documentName), documentId: "1" }],
|
||||
recipients: {
|
||||
signers: [
|
||||
{
|
||||
email: signerEmail,
|
||||
name: signerName,
|
||||
email: p.signerEmail,
|
||||
name: p.signerName,
|
||||
recipientId: "1",
|
||||
routingOrder: "1",
|
||||
// Default sign placement (bottom of page 1). Use a template/anchor
|
||||
// string for precise field placement in production.
|
||||
tabs: { signHereTabs: [{ documentId: "1", pageNumber: "1", xPosition: "100", yPosition: "650" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Envelope-level Connect: DocuSign pings our webhook on completion so we
|
||||
// pull the authoritative status. No account-level Connect config needed.
|
||||
eventNotification: {
|
||||
url: p.webhookUrl,
|
||||
loggingEnabled: "true",
|
||||
requireAcknowledgment: "true",
|
||||
envelopeEvents: [
|
||||
{ envelopeEventStatusCode: "completed" },
|
||||
{ envelopeEventStatusCode: "declined" },
|
||||
{ envelopeEventStatusCode: "voided" },
|
||||
],
|
||||
eventData: { version: "restv2.1" },
|
||||
},
|
||||
}
|
||||
const res = await fetch(`${BASE_URI}/restapi/v2.1/accounts/${ACCOUNT_ID}/envelopes`, {
|
||||
const res = await fetch(`${apiBase(creds)}/envelopes`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "Content-Type": "application/json" },
|
||||
headers: { Authorization: `Bearer ${creds.accessToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(envelope),
|
||||
})
|
||||
if (!res.ok) throw new Error(`DocuSign ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
||||
@@ -47,19 +142,32 @@ export const docusign: ESignAdapter = {
|
||||
return { externalId: j.envelopeId }
|
||||
},
|
||||
|
||||
parseWebhook(body): WebhookResult | null {
|
||||
// DocuSign Connect (JSON format) payload.
|
||||
peekExternalId(body): string | null {
|
||||
try {
|
||||
const j = JSON.parse(body) as { event?: string; data?: { envelopeId?: string; envelopeSummary?: { status?: string } } }
|
||||
const externalId = j.data?.envelopeId
|
||||
const status = (j.data?.envelopeSummary?.status ?? j.event ?? "").toLowerCase()
|
||||
if (!externalId) return null
|
||||
if (status.includes("completed") || status.includes("signed")) return { externalId, status: "signed" }
|
||||
if (status.includes("declined")) return { externalId, status: "declined" }
|
||||
if (status.includes("voided")) return { externalId, status: "voided" }
|
||||
return null
|
||||
const j = JSON.parse(body) as { data?: { envelopeId?: string } }
|
||||
return j.data?.envelopeId ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// We never trust the webhook body's status. Instead we fetch the envelope from
|
||||
// DocuSign with the owner's own OAuth token — a forged webhook can at most make
|
||||
// us re-read the real status, never fabricate a "signed".
|
||||
async verifyAndGetStatus(creds, externalId) {
|
||||
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}`, {
|
||||
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/json" },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const j = (await res.json()) as { status?: string }
|
||||
return j.status ? mapStatus(j.status) : null
|
||||
},
|
||||
|
||||
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
|
||||
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}/documents/combined`, {
|
||||
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/pdf" },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,30 +1,83 @@
|
||||
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
|
||||
import crypto from "crypto"
|
||||
import type { ESignAdapter, ESignTokens, SendParams } from "./types"
|
||||
|
||||
// Dropbox Sign (formerly HelloSign). API-key auth. Docs:
|
||||
// https://developers.hellosign.com/api/reference/operation/signatureRequestSend/
|
||||
const API_KEY = process.env.DROPBOX_SIGN_API_KEY ?? ""
|
||||
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
|
||||
// Dropbox Sign (formerly HelloSign). Per-landlord API-key auth — each landlord
|
||||
// pastes their own API key (no platform app credentials needed). Docs:
|
||||
// https://developers.hellosign.com/api/reference/
|
||||
const BASE = "https://api.hellosign.com/v3"
|
||||
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
|
||||
|
||||
function auth() {
|
||||
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64")
|
||||
function authFor(apiKey: string) {
|
||||
return "Basic " + Buffer.from(`${apiKey}:`).toString("base64")
|
||||
}
|
||||
|
||||
/** Verify the `event_hash` (hex HMAC-SHA256 of event_time+event_type, key = API key). */
|
||||
function verifyEventHash(apiKey: string, ev?: { event_type?: string; event_time?: string; event_hash?: string }): boolean {
|
||||
if (!apiKey || !ev?.event_type || !ev?.event_time || !ev?.event_hash) return false
|
||||
const expected = crypto.createHmac("sha256", apiKey).update(ev.event_time + ev.event_type).digest("hex")
|
||||
const a = Buffer.from(ev.event_hash)
|
||||
const b = Buffer.from(expected)
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
type DbxEvent = {
|
||||
event?: { event_type?: string; event_time?: string; event_hash?: string }
|
||||
signature_request?: { signature_request_id?: string }
|
||||
}
|
||||
|
||||
function parseBody(body: string): DbxEvent | null {
|
||||
try {
|
||||
// Dropbox Sign posts multipart form-data with a `json` field (or raw JSON).
|
||||
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
|
||||
return JSON.parse(m ? m[1] : body)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const dropboxSign: ESignAdapter = {
|
||||
id: "dropbox_sign",
|
||||
label: "Dropbox Sign",
|
||||
configured: () => Boolean(API_KEY),
|
||||
kind: "apikey",
|
||||
available: () => true, // landlord brings their own key; no operator setup required
|
||||
|
||||
async send({ document, documentName, signerEmail, signerName, subject, message }: SendParams) {
|
||||
getAuthUrl(): string {
|
||||
throw new Error("Dropbox Sign connects with an API key, not OAuth")
|
||||
},
|
||||
async exchangeCode(): Promise<ESignTokens> {
|
||||
throw new Error("Dropbox Sign connects with an API key, not OAuth")
|
||||
},
|
||||
async refresh(): Promise<ESignTokens> {
|
||||
throw new Error("Dropbox Sign API keys don't expire")
|
||||
},
|
||||
|
||||
async connectApiKey(apiKey): Promise<ESignTokens> {
|
||||
const key = apiKey.trim()
|
||||
if (!key) throw new Error("Enter your Dropbox Sign API key")
|
||||
const res = await fetch(`${BASE}/account`, { headers: { Authorization: authFor(key) } })
|
||||
if (res.status === 401 || res.status === 403) throw new Error("That API key was rejected by Dropbox Sign")
|
||||
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: could not validate the API key`)
|
||||
const j = (await res.json()) as { account?: { email_address?: string } }
|
||||
return {
|
||||
accessToken: key,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
accountId: null,
|
||||
baseUri: null,
|
||||
accountName: j.account?.email_address ?? "Dropbox Sign account",
|
||||
}
|
||||
},
|
||||
|
||||
async send(creds, p: SendParams) {
|
||||
const fd = new FormData()
|
||||
fd.append("subject", subject)
|
||||
fd.append("message", message)
|
||||
fd.append("subject", p.subject)
|
||||
fd.append("message", p.message)
|
||||
fd.append("test_mode", TEST_MODE)
|
||||
fd.append("signers[0][email_address]", signerEmail)
|
||||
fd.append("signers[0][name]", signerName)
|
||||
fd.append("file[0]", new Blob([new Uint8Array(document)], { type: "application/pdf" }), documentName)
|
||||
fd.append("signers[0][email_address]", p.signerEmail)
|
||||
fd.append("signers[0][name]", p.signerName)
|
||||
fd.append("file[0]", new Blob([new Uint8Array(p.document)], { type: "application/pdf" }), p.documentName)
|
||||
|
||||
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: auth() }, body: fd })
|
||||
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: authFor(creds.accessToken) }, body: fd })
|
||||
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
||||
const j = (await res.json()) as { signature_request?: { signature_request_id?: string } }
|
||||
const id = j.signature_request?.signature_request_id
|
||||
@@ -32,22 +85,30 @@ export const dropboxSign: ESignAdapter = {
|
||||
return { externalId: id }
|
||||
},
|
||||
|
||||
parseWebhook(body): WebhookResult | null {
|
||||
// Dropbox Sign posts multipart form-data with a `json` field.
|
||||
let event: { event?: { event_type?: string }; signature_request?: { signature_request_id?: string } }
|
||||
try {
|
||||
// Extract the JSON payload whether sent raw or as a form field.
|
||||
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
|
||||
event = JSON.parse(m ? m[1] : body)
|
||||
} catch {
|
||||
return null
|
||||
peekExternalId(body): string | null {
|
||||
return parseBody(body)?.signature_request?.signature_request_id ?? null
|
||||
},
|
||||
|
||||
async verifyAndGetStatus(creds, _externalId, body): Promise<"signed" | "declined" | "voided" | null> {
|
||||
const ev = parseBody(body)
|
||||
if (!ev || !verifyEventHash(creds.accessToken, ev.event)) return null
|
||||
switch (ev.event?.event_type) {
|
||||
case "signature_request_all_signed":
|
||||
return "signed"
|
||||
case "signature_request_declined":
|
||||
return "declined"
|
||||
case "signature_request_canceled":
|
||||
return "voided"
|
||||
default:
|
||||
return null
|
||||
}
|
||||
const type = event.event?.event_type
|
||||
const externalId = event.signature_request?.signature_request_id
|
||||
if (!externalId || !type) return null
|
||||
if (type === "signature_request_all_signed") return { externalId, status: "signed" }
|
||||
if (type === "signature_request_declined") return { externalId, status: "declined" }
|
||||
if (type === "signature_request_canceled") return { externalId, status: "voided" }
|
||||
return null
|
||||
},
|
||||
|
||||
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
|
||||
const res = await fetch(`${BASE}/signature_request/files/${externalId}?file_type=pdf`, {
|
||||
headers: { Authorization: authFor(creds.accessToken) },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,42 +1,48 @@
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { leases, signature_requests } from "@/lib/db/schema"
|
||||
import { readFile } from "@/lib/storage"
|
||||
import { docusign } from "./docusign"
|
||||
import { dropboxSign } from "./dropbox-sign"
|
||||
import type { ESignAdapter, ESignProvider } from "./types"
|
||||
import { readFile, saveBuffer } from "@/lib/storage"
|
||||
import { getAdapter } from "./registry"
|
||||
import { resolveEsignCreds, markEsignError } from "./credentials"
|
||||
import type { ESignProvider } from "./types"
|
||||
|
||||
export type { ESignProvider } from "./types"
|
||||
export { getAdapter, listEsignAdapters } from "./registry"
|
||||
export {
|
||||
resolveEsignCreds,
|
||||
listEsignConnections,
|
||||
getEsignConnection,
|
||||
saveEsignConnection,
|
||||
disconnectEsign,
|
||||
} from "./credentials"
|
||||
|
||||
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
|
||||
const FILES_PREFIX = "/api/files/"
|
||||
|
||||
export function getAdapter(id: string): ESignAdapter | null {
|
||||
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
|
||||
}
|
||||
|
||||
export function listAdapters() {
|
||||
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({ id, label: ADAPTERS[id].label, configured: ADAPTERS[id].configured() }))
|
||||
}
|
||||
|
||||
export function anyEsignConfigured(): boolean {
|
||||
return listAdapters().some((a) => a.configured)
|
||||
/** The webhook URL a provider should call back — used for DocuSign envelope-level Connect. */
|
||||
function webhookUrl(provider: ESignProvider): string {
|
||||
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
||||
return `${base}/api/esign/${provider}/webhook`
|
||||
}
|
||||
|
||||
/** Read the lease's stored document. Only /api/files keys are allowed (no SSRF). */
|
||||
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
|
||||
const prefix = "/api/files/"
|
||||
if (documentUrl.startsWith(prefix)) {
|
||||
const key = documentUrl.slice(prefix.length)
|
||||
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
|
||||
if (!documentUrl.startsWith(FILES_PREFIX)) {
|
||||
throw new Error("Lease document must be an uploaded file")
|
||||
}
|
||||
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" }
|
||||
const key = documentUrl.slice(FILES_PREFIX.length)
|
||||
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a lease for signature through the owner's OWN connected account.
|
||||
* Requires the provider to be connected (per-user OAuth / API key).
|
||||
*/
|
||||
export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
|
||||
const adapter = getAdapter(provider)
|
||||
if (!adapter) throw new Error("Unknown provider")
|
||||
if (!adapter.configured()) throw new Error(`${adapter.label} is not configured`)
|
||||
|
||||
const creds = await resolveEsignCreds(ownerId, provider)
|
||||
if (!creds) throw new Error(`Connect your ${adapter.label} account in Settings → Integrations first`)
|
||||
|
||||
const lease = await db.query.leases.findFirst({
|
||||
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)),
|
||||
@@ -51,13 +57,14 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
|
||||
const { bytes, name } = await getDocumentBytes(lease.document_url)
|
||||
|
||||
try {
|
||||
const { externalId } = await adapter.send({
|
||||
const { externalId } = await adapter.send(creds, {
|
||||
document: bytes,
|
||||
documentName: name,
|
||||
signerEmail: email,
|
||||
signerName,
|
||||
subject: "Please sign your lease agreement",
|
||||
message: "Your landlord has sent your lease agreement for electronic signature.",
|
||||
webhookUrl: webhookUrl(provider),
|
||||
})
|
||||
const [row] = await db
|
||||
.insert(signature_requests)
|
||||
@@ -66,6 +73,7 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
|
||||
return row
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message.slice(0, 500)
|
||||
await markEsignError(ownerId, provider, msg)
|
||||
await db
|
||||
.insert(signature_requests)
|
||||
.values({ user_id: ownerId, lease_id: leaseId, provider, status: "error", signer_email: email, signer_name: signerName, document_name: name, last_error: msg })
|
||||
@@ -80,18 +88,53 @@ export async function listRequestsForLease(ownerId: string, leaseId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Update a request's status from an inbound provider webhook. */
|
||||
/**
|
||||
* Process an inbound provider webhook. The body is UNTRUSTED: we use it only to
|
||||
* find which signature request (and therefore which owner + credentials) it
|
||||
* concerns, then authenticate the event via the adapter (DocuSign pull-verify /
|
||||
* Dropbox HMAC) before updating status and archiving the signed document.
|
||||
*/
|
||||
export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
|
||||
const adapter = getAdapter(provider)
|
||||
if (!adapter) return
|
||||
const result = adapter.parseWebhook(body, headers)
|
||||
if (!result) return
|
||||
|
||||
const externalId = adapter.peekExternalId(body)
|
||||
if (!externalId) return
|
||||
|
||||
const reqRow = await db.query.signature_requests.findFirst({
|
||||
where: eq(signature_requests.external_id, externalId),
|
||||
columns: { id: true, user_id: true, status: true },
|
||||
})
|
||||
if (!reqRow) return
|
||||
|
||||
const creds = await resolveEsignCreds(reqRow.user_id, provider as ESignProvider)
|
||||
if (!creds) return
|
||||
|
||||
const status = await adapter.verifyAndGetStatus(creds, externalId, body, headers)
|
||||
if (!status) return
|
||||
|
||||
await db
|
||||
.update(signature_requests)
|
||||
.set({
|
||||
status: result.status,
|
||||
completed_at: result.status === "signed" ? new Date().toISOString() : null,
|
||||
status,
|
||||
completed_at: status === "signed" ? new Date().toISOString() : null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(signature_requests.external_id, result.externalId))
|
||||
.where(eq(signature_requests.id, reqRow.id))
|
||||
|
||||
// Archive the executed document so the landlord can download the signed copy.
|
||||
if (status === "signed") {
|
||||
try {
|
||||
const bytes = await adapter.getSignedDocument(creds, externalId)
|
||||
if (bytes && bytes.length) {
|
||||
const { key } = await saveBuffer(bytes, { userId: reqRow.user_id, scope: "esign", ext: "pdf" })
|
||||
await db
|
||||
.update(signature_requests)
|
||||
.set({ signed_document_url: `${FILES_PREFIX}${key}` })
|
||||
.where(eq(signature_requests.id, reqRow.id))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — status is already recorded.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
export type ESignProvider = "docusign" | "dropbox_sign"
|
||||
|
||||
export type ESignStatus = "signed" | "declined" | "voided"
|
||||
|
||||
/** Result of connecting an account (OAuth exchange or API-key validation). */
|
||||
export interface ESignTokens {
|
||||
accessToken: string
|
||||
refreshToken: string | null
|
||||
/** ISO expiry of the access token, or null (API keys don't expire). */
|
||||
expiresAt: string | null
|
||||
accountId: string | null
|
||||
baseUri: string | null
|
||||
accountName: string | null
|
||||
}
|
||||
|
||||
/** Decrypted, ready-to-use credentials for a single connected account. */
|
||||
export interface ESignCredentials {
|
||||
provider: ESignProvider
|
||||
accessToken: string
|
||||
refreshToken?: string | null
|
||||
accountId?: string | null
|
||||
baseUri?: string | null
|
||||
}
|
||||
|
||||
export interface SendParams {
|
||||
document: Buffer
|
||||
documentName: string
|
||||
@@ -7,20 +29,54 @@ export interface SendParams {
|
||||
signerName: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
externalId: string
|
||||
status: "signed" | "declined" | "voided"
|
||||
/** Our callback the provider should ping on status changes (DocuSign envelope-level Connect). */
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
export interface ESignAdapter {
|
||||
id: ESignProvider
|
||||
label: string
|
||||
/** True when this provider's credentials are configured in env. */
|
||||
configured(): boolean
|
||||
/** Send a document for signature; returns the provider's request/envelope id. */
|
||||
send(p: SendParams): Promise<{ externalId: string }>
|
||||
/** Parse an inbound webhook body into a status update (or null to ignore). */
|
||||
parseWebhook(body: string, headers: Headers): WebhookResult | null
|
||||
/** "oauth" → connect via redirect; "apikey" → connect by pasting a key. */
|
||||
kind: "oauth" | "apikey"
|
||||
/**
|
||||
* True when the platform can offer this provider. OAuth providers need the
|
||||
* operator's app credentials (client id/secret); API-key providers are always
|
||||
* available because the landlord brings their own key.
|
||||
*/
|
||||
available(): boolean
|
||||
|
||||
// ── OAuth providers (DocuSign) ────────────────────────────────────────────
|
||||
getAuthUrl(state: string): string
|
||||
exchangeCode(code: string): Promise<ESignTokens>
|
||||
refresh(refreshToken: string): Promise<ESignTokens>
|
||||
|
||||
// ── API-key providers (Dropbox Sign) ──────────────────────────────────────
|
||||
/** Validate a pasted API key and return a token set to store. */
|
||||
connectApiKey(apiKey: string): Promise<ESignTokens>
|
||||
|
||||
// ── Common ────────────────────────────────────────────────────────────────
|
||||
/** Send a document for signature; returns the provider's envelope/request id. */
|
||||
send(creds: ESignCredentials, params: SendParams): Promise<{ externalId: string }>
|
||||
/** Extract the external id from an inbound (still UNVERIFIED) webhook body, for owner lookup. */
|
||||
peekExternalId(body: string): string | null
|
||||
/**
|
||||
* Authenticate an inbound webhook and return the authoritative status.
|
||||
* DocuSign pull-verifies by fetching the envelope with the owner's token;
|
||||
* Dropbox Sign HMAC-verifies the body with the account API key. Returns null
|
||||
* if the event isn't authentic or isn't a terminal status we track.
|
||||
*/
|
||||
verifyAndGetStatus(
|
||||
creds: ESignCredentials,
|
||||
externalId: string,
|
||||
body: string,
|
||||
headers: Headers
|
||||
): Promise<ESignStatus | null>
|
||||
/** Download the completed/executed document, or null if unavailable. */
|
||||
getSignedDocument(creds: ESignCredentials, externalId: string): Promise<Buffer | null>
|
||||
}
|
||||
|
||||
/** The OAuth callback URL for a provider (must match what's registered in the provider app). */
|
||||
export function esignRedirectUri(provider: ESignProvider): string {
|
||||
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
||||
return `${base}/api/esign/${provider}/callback`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,53 @@ function sanitizeSegment(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff a storage key lives in the given owner's namespace (`<ownerId>/…`).
|
||||
* Keys are generated server-side as `<sanitized ownerId>/<scope>/<file>`, so any
|
||||
* client-supplied key/path whose first segment differs belongs to another tenant
|
||||
* (or is malformed) and must be rejected.
|
||||
*/
|
||||
export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean {
|
||||
if (!key || !ownerId) return false
|
||||
const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0]
|
||||
return first === sanitizeSegment(ownerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight magic-byte check: reject a file whose real content doesn't match
|
||||
* its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types
|
||||
* without a reliable file signature (csv/txt) are allowed through. `head` should
|
||||
* be the first ~16 bytes of the file.
|
||||
*/
|
||||
export function contentMatchesExtension(head: Buffer, ext: string): boolean {
|
||||
const at = (offset: number, sig: number[]) =>
|
||||
head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b)
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF
|
||||
case "png":
|
||||
return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
return at(0, [0xff, 0xd8, 0xff])
|
||||
case "gif":
|
||||
return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8
|
||||
case "webp":
|
||||
return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP
|
||||
case "docx":
|
||||
case "xlsx":
|
||||
return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK)
|
||||
case "doc":
|
||||
case "xls":
|
||||
return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip
|
||||
case "csv":
|
||||
case "txt":
|
||||
return true // no reliable signature
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
|
||||
if (!body) return Buffer.alloc(0)
|
||||
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
|
||||
@@ -194,6 +241,36 @@ export async function saveFile(
|
||||
return { key, size: file.size, type }
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist raw bytes under `${userId}/${scope}/<random>.<ext>` (server-generated,
|
||||
* so the key is always in the owner's namespace) and return the storage key.
|
||||
* Used for server-side artifacts like signed e-sign PDFs.
|
||||
*/
|
||||
export async function saveBuffer(
|
||||
buffer: Buffer,
|
||||
opts: { userId: string; scope: string; ext: string }
|
||||
): Promise<{ key: string }> {
|
||||
const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"
|
||||
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}`
|
||||
if (usingSpaces()) {
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: SPACES_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentTypeForKey(key),
|
||||
ACL: "private",
|
||||
})
|
||||
)
|
||||
} else {
|
||||
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
|
||||
const abs = resolveKey(key)
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
await fs.writeFile(abs, buffer)
|
||||
}
|
||||
return { key }
|
||||
}
|
||||
|
||||
export async function readFile(key: string): Promise<Buffer> {
|
||||
if (usingSpaces()) {
|
||||
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
|
||||
@@ -227,7 +304,10 @@ export async function presignGetUrl(
|
||||
return toCdnUrl(signed)
|
||||
}
|
||||
|
||||
export async function deleteFile(key: string): Promise<void> {
|
||||
export async function deleteFile(key: string, ownerId: string): Promise<void> {
|
||||
// Defense in depth: never delete an object outside the caller's own namespace,
|
||||
// even if a stored storage_path was tampered with to point at another tenant.
|
||||
if (!keyBelongsToOwner(key, ownerId)) return
|
||||
try {
|
||||
if (usingSpaces()) {
|
||||
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
// NOTE: The Content-Security-Policy is set per-request in `proxy.ts` (Next
|
||||
// middleware) so `script-src` can carry a per-request nonce instead of
|
||||
@@ -31,4 +32,16 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withSentryConfig(nextConfig, {
|
||||
org: "phluit",
|
||||
project: "property-management-network",
|
||||
// Only print source-map upload logs in CI.
|
||||
silent: !process.env.CI,
|
||||
// Upload a wider set of client source maps for readable stack traces.
|
||||
widenClientFileUpload: true,
|
||||
// Tree-shake Sentry's debug logger to shrink the client bundle.
|
||||
disableLogger: true,
|
||||
// Source-map upload runs at build time only when SENTRY_AUTH_TOKEN is set;
|
||||
// without it the build still succeeds (stack traces just aren't un-minified).
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.110.0",
|
||||
"@aws-sdk/client-s3": "^3.1077.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1078.0",
|
||||
"@fullcalendar/daygrid": "^6.1.21",
|
||||
@@ -22,6 +23,7 @@
|
||||
"@fullcalendar/react": "^6.1.21",
|
||||
"@fullcalendar/timegrid": "^6.1.21",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@sentry/nextjs": "^10.63.0",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"better-auth": "^1.6.20",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -28,6 +28,19 @@ const PROTECTED_PATHS = [
|
||||
|
||||
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
|
||||
|
||||
// Origin of the Sentry ingest endpoint, derived from the public DSN so the
|
||||
// CSP stays in sync with whatever project/region the DSN points at. Returns
|
||||
// null when Sentry is not configured.
|
||||
function sentryIngestOrigin(): string | null {
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
if (!dsn) return null
|
||||
try {
|
||||
return new URL(dsn).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Build the per-request Content-Security-Policy. `script-src` carries a
|
||||
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps
|
||||
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline
|
||||
@@ -36,6 +49,7 @@ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
|
||||
// scripts automatically.
|
||||
function buildCsp(nonce: string): string {
|
||||
const isDev = process.env.NODE_ENV !== "production"
|
||||
const sentry = sentryIngestOrigin()
|
||||
|
||||
// In development, Next.js/React and Turbopack HMR require eval() for hot
|
||||
// reloading and debugging features, and open a dev websocket. These are NOT
|
||||
@@ -43,9 +57,14 @@ function buildCsp(nonce: string): string {
|
||||
const scriptSrc = isDev
|
||||
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com`
|
||||
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`
|
||||
const connectSrc = isDev
|
||||
? "connect-src 'self' ws: wss: https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
|
||||
: "connect-src 'self' https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
|
||||
const connectSrc = [
|
||||
"connect-src 'self'",
|
||||
isDev ? "ws: wss:" : "",
|
||||
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
|
||||
sentry ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
@@ -54,13 +73,39 @@ function buildCsp(nonce: string): string {
|
||||
scriptSrc,
|
||||
"font-src 'self' data:",
|
||||
connectSrc,
|
||||
// Sentry Session Replay spins up its compression worker from a blob: URL;
|
||||
// without worker-src the browser falls back to script-src and blocks it.
|
||||
"worker-src 'self' blob:",
|
||||
"frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join("; ")
|
||||
}
|
||||
|
||||
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
|
||||
// an auth page we confirm the session is actually alive — otherwise a stale
|
||||
// cookie loops forever: /dashboard → /login (server sees no session) →
|
||||
// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS.
|
||||
// "unknown" (auth service unreachable / rate-limited) renders the auth page
|
||||
// without touching cookies, which is safe in both directions.
|
||||
async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> {
|
||||
try {
|
||||
const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin
|
||||
const res = await fetch(new URL("/api/auth/get-session", base), {
|
||||
headers: { cookie: request.headers.get("cookie") ?? "" },
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!res.ok) return "unknown"
|
||||
// Better Auth returns JSON `null` when the session is missing or revoked.
|
||||
const session = await res.json()
|
||||
return session ? "valid" : "invalid"
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
|
||||
@@ -76,10 +121,18 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
|
||||
let dropStaleSessionCookie = false
|
||||
if (isAuthPage && sessionCookie) {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = "/dashboard"
|
||||
return NextResponse.redirect(url)
|
||||
const state = await sessionState(request)
|
||||
if (state === "valid") {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = "/dashboard"
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
// Dead cookie (session revoked or expired): render the auth page and drop
|
||||
// the cookie below so protected paths stop treating this visitor as
|
||||
// signed in. On "unknown", render the page but keep the cookie.
|
||||
dropStaleSessionCookie = state === "invalid"
|
||||
}
|
||||
|
||||
// Per-request CSP nonce. UUID contains only hex + dashes, so it never
|
||||
@@ -96,6 +149,21 @@ export async function proxy(request: NextRequest) {
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } })
|
||||
// Also set the CSP on the outgoing response so the browser enforces it.
|
||||
response.headers.set("Content-Security-Policy", csp)
|
||||
|
||||
if (dropStaleSessionCookie) {
|
||||
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
|
||||
for (const cookie of request.cookies.getAll()) {
|
||||
if (!cookie.name.includes("better-auth.session_token")) continue
|
||||
response.cookies.set(cookie.name, "", {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: cookie.name.startsWith("__Secure-"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +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 +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 |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -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 +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 +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 |
@@ -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"))
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||