6 Commits
Author SHA1 Message Date
Leon SerfatyandClaude Opus 4.8 595a5e3e04 feat(auth): hide Google sign-in until OAuth is configured
Google social login has no credentials in production, so the "Continue with
Google" button (and its divider) errored on click. Gate the button + divider on
a new isGoogleConfigured() helper (requires GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET)
on both the login and signup pages, and guard the signInWithGoogle action as
defense in depth. The button reappears automatically once both env vars are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:26:08 -04:00
Leon SerfatyandClaude Opus 4.8 e5e987eb4c fix(csp): whitelist Umami analytics origin so tracking works
The CSP script-src/connect-src didn't include the Umami host
(fickanalytics.phluit.net), so the browser blocked both loading script.js and
the event beacons (POST /api/send) — analytics recorded 0 visits despite the
site being live. Add a umamiOrigin() helper (derived from NEXT_PUBLIC_UMAMI_SRC,
defaulting to the shared phluit instance) and include it in script-src and
connect-src.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:37:35 -04:00
Leon SerfatyandClaude Fable 5 5a555c715e Build GDPR compliance system: data export, account deletion, consent
- Data export (Art. 15/20): GET /api/gdpr/export serves a full JSON export
  of the user's data (credentials/tokens excluded, exclusions declared)
- Right to erasure (Art. 17): self-service deletion with 30-day grace
  period (Settings -> Privacy & Data), cancellable; daily /api/cron/gdpr
  drain cancels Stripe billing, purges Spaces files, cascade-deletes the
  account, anonymizes consent rows, and writes audit evidence
- Migration 0011: account_deletion_requests (partial unique index = one
  pending per user) + FK-less consent_log (survives erasure)
- Consent: terms/privacy acceptance logged at signup (email + Google);
  cookie banner with analytics opt-out (umami.disabled), choices logged
  server-side for signed-in users via POST /api/gdpr/consent
- Admin deleteUser upgraded to the same full purge (was leaving Spaces
  files and Stripe subscriptions orphaned)
- /gdpr legal page now points at the self-service tools
- scripts/verify-gdpr.ts: end-to-end verification vs live dev DB (22/22)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 06:03:27 -04:00
Leon SerfatyandClaude Opus 4.8 0d11018019 fix(csp): allow inline scripts so the app hydrates on Turbopack builds
Next.js 16 builds with Turbopack, which does NOT stamp the middleware CSP
nonce onto its inline hydration scripts (self.__next_f.push). The nonce-based
`script-src 'self' 'nonce-…'` therefore blocked those inline scripts, React
never hydrated, and the marketing/app pages rendered as a blank/black shell
(header + framer-motion sections stuck at opacity:0).

Switch `script-src` to 'self' 'unsafe-inline' (Turbopack-compatible) and drop
the now-unused nonce plumbing. All other CSP directives stay strict
(object-src 'none', frame-ancestors 'none', locked connect-src/frame-src).
Verified in a local production container: served script-src is correct and
the page hydrates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:30:39 -04:00
Leon SerfatyandClaude Opus 4.8 5495b94924 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>
2026-07-03 04:45:24 -04:00
Leon SerfatyandClaude Opus 4.8 917a06ee85 docs: overhaul README and mark project proprietary
- Rewrite README: accurate current feature set (public API, webhooks/Zapier,
  PayPal, accounting sync, e-sign, team, branding, inspections, maps, ~30 tables,
  4 cron jobs), emoji section headers, clearer setup and security sections.
- Remove the inline env-variable example block; point to .env.example / DIGITALOCEAN.md.
- Fix stale "local disk" storage references to DigitalOcean Spaces.
- License: change MIT -> Proprietary; add LICENSE file and package.json "UNLICENSED".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:51:59 -04:00
108 changed files with 12962 additions and 1382 deletions
+91 -47
View File
@@ -1,30 +1,28 @@
# ─────────────────────────────────────────────────────────────────────────────
# DigitalOcean App Platform spec — Property Management Network
#
# Deploy: doctl apps create --spec .do/app.yaml
# Deploy: doctl apps create --spec .do/app.yaml (or the DO MCP apps-create)
# Update: doctl apps update <APP_ID> --spec .do/app.yaml
#
# SOURCE: image-based from DigitalOcean Container Registry (DOCR). The app's git
# lives on self-hosted Gitea, which App Platform cannot pull, so we build the
# Docker image ourselves and push it to DOCR. See DIGITALOCEAN.md for the full
# build/push/deploy walkthrough.
# SOURCE: App Platform builds the Dockerfile directly from GitHub
# (github.com/silkoserfo/property-management-network). Pushes to `main`
# auto-redeploy (deploy_on_push). No DOCR image build/push needed.
#
# SECRETS: values marked `type: SECRET` are placeholders — set the real values in
# the App Platform dashboard (App → Settings → Environment Variables) or via
# `doctl`. Never commit real secrets to this file.
# the App Platform dashboard (App → Settings → Environment Variables) or via the
# create spec. Never commit real secrets to this file.
# ─────────────────────────────────────────────────────────────────────────────
name: property-management-network
region: nyc
services:
- name: web
# Pre-built image pushed to DOCR (repository must exist in your registry).
image:
registry_type: DOCR
repository: property-management-network
tag: latest
deploy_on_push:
enabled: true
# Built by App Platform from GitHub using the repo Dockerfile.
github:
repo: silkoserfo/property-management-network
branch: main
deploy_on_push: true
dockerfile_path: Dockerfile
instance_count: 1
instance_size_slug: apps-s-1vcpu-1gb
http_port: 3000
@@ -37,32 +35,35 @@ services:
failure_threshold: 3
envs:
# ── App URLs ──────────────────────────────────────────────────────────
# ${APP_URL} resolves to the app's public URL at runtime. NOTE: the client
# bundle bakes NEXT_PUBLIC_APP_URL at *image build* time (see Dockerfile /
# DIGITALOCEAN.md), so build the image with the same URL you serve on.
# NEXT_PUBLIC_* are inlined into the client bundle at BUILD time, so they
# must be RUN_AND_BUILD_TIME with the literal domain we serve on.
- key: NEXT_PUBLIC_APP_URL
scope: RUN_TIME
value: ${APP_URL}
scope: RUN_AND_BUILD_TIME
value: https://propertymanagement.network
- key: BETTER_AUTH_URL
scope: RUN_TIME
value: ${APP_URL}
value: https://propertymanagement.network
- key: NEXT_PUBLIC_APP_NAME
scope: RUN_TIME
scope: RUN_AND_BUILD_TIME
value: Property Management Network
# ── Database (managed Postgres — use the PRIVATE host; see DIGITALOCEAN.md) ──
# ── Admin & auth policy ───────────────────────────────────────────────
- key: ADMIN_EMAILS
scope: RUN_TIME
value: leon@phluit.com
- key: REQUIRE_EMAIL_VERIFICATION
scope: RUN_TIME
value: "true"
# ── Database (managed Postgres — PRIVATE host, direct port 25060) ──
- key: DATABASE_URL
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# Verified TLS (encrypted + certificate-checked). DO Managed Postgres uses
# a CA that isn't in the system trust store, so paste the cluster's CA cert
# into DATABASE_CA: DO control panel → Database → Connection Details →
# "Download CA certificate", then paste its PEM contents as the DATABASE_CA
# secret in the App Platform dashboard. Without a valid CA the app will
# refuse to connect (fail loud) rather than run unverified.
# Emergency fallback ONLY (not for production): DATABASE_SSL=no-verify is
# encrypted but does NOT verify the server certificate.
# Verified TLS: DO's Managed Postgres CA isn't in the system trust store, so
# paste the cluster CA PEM (repo root ca-certificate.crt) into DATABASE_CA.
# With `require` + a valid CA the app connects verified; without a valid CA
# it fails loud rather than run unverified.
- key: DATABASE_SSL
scope: RUN_TIME
value: require
@@ -70,8 +71,7 @@ services:
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# Schema is migrated out-of-band (as doadmin), NOT on boot — the app user
# intentionally lacks DDL rights. Keep this false; run migrations manually.
# Schema is migrated out-of-band (as doadmin), NOT on boot.
- key: RUN_MIGRATIONS_ON_START
scope: RUN_TIME
value: "false"
@@ -81,14 +81,15 @@ services:
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# Google OAuth (optional — leave blank to disable the Google button).
- key: GOOGLE_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
value: ""
- key: GOOGLE_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
value: ""
# ── Stripe ────────────────────────────────────────────────────────────
- key: STRIPE_SECRET_KEY
@@ -99,22 +100,21 @@ services:
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# No STRIPE_*_PRICE_ID vars — prices are resolved by lookup key and
# auto-created on first checkout (lib/stripe/prices.ts). Going live only
# needs the two live secrets above + the live publishable key below.
# ── OpenAI ────────────────────────────────────────────────────────────
- key: OPENAI_API_KEY
# ── AI provider (Anthropic default; OpenAI optional) ──────────────────
- key: ANTHROPIC_API_KEY
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
- key: ANTHROPIC_MODEL
scope: RUN_TIME
value: claude-haiku-4-5
- key: OPENAI_API_KEY
scope: RUN_TIME
type: SECRET
value: ""
# ── Email (SMTP — SMTP2GO) ────────────────────────────────────────────
# The app sends mail via SMTP only (nodemailer). Email is silently skipped
# unless SMTP_HOST + SMTP_USER + SMTP_PASS are all set — password resets,
# email verification, rent/overdue/lease reminders, team invites, and
# payment links all depend on this. EMAIL_FROM is a bare address; the app
# wraps it as "Property Management Network <…>".
- key: SMTP_HOST
scope: RUN_TIME
value: mail.smtp2go.com
@@ -133,10 +133,9 @@ services:
scope: RUN_TIME
value: postmaster@propertymanagement.network
# ── Cloudflare Turnstile (site key is public; baked into the client bundle
# at image build time — keep it in sync when you build) ──
# ── Cloudflare Turnstile (site key public; baked at build time) ──
- key: NEXT_PUBLIC_TURNSTILE_SITE_KEY
scope: RUN_TIME
scope: RUN_AND_BUILD_TIME
value: 0x4AAAAAADuDQverznfv1a60
- key: TURNSTILE_SECRET_KEY
scope: RUN_TIME
@@ -165,8 +164,53 @@ services:
scope: RUN_TIME
value: https://nyc3.cdn.digitaloceanspaces.com
# ── Accounting sync (optional — per-landlord QuickBooks / Xero OAuth) ──
- key: QBO_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: ""
- key: QBO_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: ""
- key: QBO_ENVIRONMENT
scope: RUN_TIME
value: production
- key: XERO_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: ""
- key: XERO_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: ""
- key: XERO_SALES_ACCOUNT_CODE
scope: RUN_TIME
value: "200"
- key: XERO_EXPENSE_ACCOUNT_CODE
scope: RUN_TIME
value: "400"
# ── Error monitoring (Sentry — DSN is public; browser DSN baked at build) ──
- key: SENTRY_DSN
scope: RUN_TIME
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
- key: NEXT_PUBLIC_SENTRY_DSN
scope: RUN_AND_BUILD_TIME
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
- key: SENTRY_ENVIRONMENT
scope: RUN_TIME
value: production
# ── Cron (Bearer token the DO Function sends to /api/cron/*) ──
- key: CRON_SECRET
scope: RUN_TIME
type: SECRET
value: REPLACE_IN_DASHBOARD
# ── Custom domains (DNS hosted on Cloudflare — set CNAMEs there, DNS-only) ──
domains:
- domain: propertymanagement.network
type: PRIMARY
- domain: www.propertymanagement.network
type: ALIAS
+29 -27
View File
@@ -50,24 +50,16 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key
# auto-creates them on first checkout (lib/stripe/prices.ts), so going live is a
# pure key swap. Optionally pre-create the catalog: node scripts/stripe-setup.mjs
# === PAYPAL (optional — alternative subscription checkout) ===
# Lets landlords pay for their plan with PayPal alongside Stripe. Leave blank to
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com;
# keep PAYPAL_ENVIRONMENT=sandbox for testing. Create a webhook pointing to
# <APP_URL>/api/paypal/webhook and set its id as PAYPAL_WEBHOOK_ID. Generate the
# plan IDs once with `node scripts/paypal-setup-plans.mjs` and paste them below.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=sandbox
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) ===
# Get from: https://platform.openai.com/api-keys
# === AI PROVIDER (OpenAI and/or Anthropic) ===
# The active provider is chosen by an admin in Settings → System. Configure the
# key(s) for whichever provider(s) you want available; the app falls back to the
# configured one if the selected provider's key is missing.
# OpenAI — https://platform.openai.com/api-keys
OPENAI_API_KEY=sk-your-api-key
# OPENAI_MODEL=gpt-4o-mini
# Anthropic (Claude) — https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=
# ANTHROPIC_MODEL=claude-haiku-4-5 # cheapest; use claude-sonnet-5 / claude-opus-4-8 for more capability
# === EMAIL (SMTP — e.g. SMTP2GO) ===
# Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS.
@@ -87,16 +79,18 @@ QBO_ENVIRONMENT=sandbox
XERO_CLIENT_ID=
XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) ===
# Dropbox Sign: API-key auth. Set DROPBOX_SIGN_TEST_MODE=true while testing.
DROPBOX_SIGN_API_KEY=
DROPBOX_SIGN_TEST_MODE=true
# DocuSign: uses a pre-obtained access token (JWT/OAuth). Webhook: DocuSign
# Connect → <APP_URL>/api/esign/docusign/webhook ; Dropbox Sign callback →
# <APP_URL>/api/esign/dropbox_sign/webhook
DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_ACCOUNT_ID=
DOCUSIGN_BASE_URI=https://demo.docusign.net
# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# DocuSign: register ONE DocuSign app (integration key) here; each landlord then
# connects their own DocuSign account via OAuth from Settings → Integrations.
# Redirect URI to register in the DocuSign app: <APP_URL>/api/esign/docusign/callback
# DOCUSIGN_OAUTH_BASE: account-d.docusign.com (demo) or account.docusign.com (prod).
DOCUSIGN_CLIENT_ID=
DOCUSIGN_CLIENT_SECRET=
DOCUSIGN_OAUTH_BASE=account-d.docusign.com
# Dropbox Sign: no server credentials — landlords paste their own API key in the
# app and set their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
# DROPBOX_SIGN_TEST_MODE applies test mode to all outbound requests (optional).
DROPBOX_SIGN_TEST_MODE=false
# === APP ===
NEXT_PUBLIC_APP_URL=http://localhost:3000
@@ -113,6 +107,14 @@ CRON_SECRET=your-random-secret-string
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
# === ERROR MONITORING (Sentry — optional) ===
# Paste the DSN from your Sentry project (Settings → Client Keys / DSN). It's
# public (ships in the browser bundle). Sentry stays inert until this is set.
NEXT_PUBLIC_SENTRY_DSN=
# Build-time only: uploads source maps for readable stack traces. Create at
# Sentry → Settings → Auth Tokens. Keep secret; leave blank to skip upload.
SENTRY_AUTH_TOKEN=
# === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Property addresses are geocoded on save via OpenStreetMap Nominatim and shown
# on a Leaflet map (both keyless & free). Nominatim's policy requires an
+18 -25
View File
@@ -68,21 +68,6 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
# first checkout, so going live is ONLY the three values above (live keys + live
# webhook secret). Optionally pre-create the catalog: node scripts/stripe-setup.mjs
# === PAYPAL (optional — alternative subscription checkout) ===
# Landlords can pay for their plan with PayPal alongside Stripe. Leave blank to
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com and
# set PAYPAL_ENVIRONMENT=live for production. Create a webhook there pointing to
# <APP_URL>/api/paypal/webhook and put its id in PAYPAL_WEBHOOK_ID. Generate the
# plan IDs with `node scripts/paypal-setup-plans.mjs`.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=live
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) ===
OPENAI_API_KEY=sk-xxx
@@ -103,17 +88,17 @@ QBO_ENVIRONMENT=production
XERO_CLIENT_ID=
XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) ===
# Leave blank to hide/disable a provider on the lease page. Configure the
# provider callbacks to point at this app:
# Dropbox Sign callback → <APP_URL>/api/esign/dropbox_sign/webhook
# DocuSign Connect → <APP_URL>/api/esign/docusign/webhook
# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs.
DROPBOX_SIGN_API_KEY=
# === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# DocuSign: register ONE DocuSign app; landlords connect their own account via
# OAuth from Settings → Integrations. Register this redirect URI in the app:
# <APP_URL>/api/esign/docusign/callback
# Use account.docusign.com in production (account-d.docusign.com for demo).
DOCUSIGN_CLIENT_ID=
DOCUSIGN_CLIENT_SECRET=
DOCUSIGN_OAUTH_BASE=account.docusign.com
# Dropbox Sign: no server credentials — landlords paste their own API key and set
# their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
DROPBOX_SIGN_TEST_MODE=false
DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_ACCOUNT_ID=
DOCUSIGN_BASE_URI=https://www.docusign.net
# === APP (NEXT_PUBLIC_* — also set as Build Variables) ===
NEXT_PUBLIC_APP_URL=https://propertymanagement.network
@@ -128,6 +113,14 @@ GOOGLE_SITE_VERIFICATION=
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
# === ERROR MONITORING (Sentry) ===
# DSN from your Sentry project (public — inlined in the browser bundle, so set
# it as a Build Variable too). Error monitoring is disabled until this is set.
NEXT_PUBLIC_SENTRY_DSN=
# Build-time secret: uploads source maps so prod stack traces are un-minified.
# Sentry → Settings → Auth Tokens. Set as a Build Variable; leave blank to skip.
SENTRY_AUTH_TOKEN=
# === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM
# tiles. No API key or billing. Nominatim REQUIRES an identifying User-Agent —
+3
View File
@@ -34,6 +34,9 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# MCP config — contains a DigitalOcean API token; keep local, never commit.
.mcp.json
# env files (can opt-in for committing if needed)
.env*
!.env.example
+12 -1
View File
@@ -94,11 +94,19 @@ docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
--build-arg NEXT_PUBLIC_SENTRY_DSN=<your-sentry-dsn> \
--build-arg SENTRY_AUTH_TOKEN=<optional-for-source-maps> \
-t $REG/property-management-network:latest .
docker push $REG/property-management-network:latest
```
> **Sentry:** the browser DSN is baked at build time, so it must be a `--build-arg`
> (setting `NEXT_PUBLIC_SENTRY_DSN` only in the dashboard won't reach the client). The
> server/edge runtimes read `SENTRY_DSN` at runtime (set in the dashboard). Both stay inert
> until a DSN is provided, so it's safe to omit until you're ready. `SENTRY_AUTH_TOKEN` is
> optional and only uploads source maps for readable stack traces.
> First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the
> `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`.
@@ -114,7 +122,10 @@ Then set every `type: SECRET` value (App → Settings → Environment Variables)
`.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`,
`BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`,
`OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`,
`SPACES_SECRET`, `CRON_SECRET` (plus the Stripe price IDs). Email sends via **SMTP
`SPACES_SECRET`, `CRON_SECRET`. Optional integrations (leave blank to keep hidden):
`QBO_CLIENT_ID/SECRET` + `XERO_CLIENT_ID/SECRET` (accounting), `DOCUSIGN_CLIENT_ID/SECRET`
(e-signature — not yet in the spec; add if used), and `SENTRY_DSN` (error monitoring —
plus the `NEXT_PUBLIC_SENTRY_DSN` build-arg above). Email sends via **SMTP
(SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without
`SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves
for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime.
+8
View File
@@ -24,9 +24,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_APP_NAME="Property Management Network"
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
# Client-side Sentry DSN — inlined into the browser bundle. Without it, only
# server/edge errors are reported (SENTRY_DSN at runtime); the browser stays inert.
ARG NEXT_PUBLIC_SENTRY_DSN
# Optional: a Sentry auth token uploads source maps for readable stack traces.
# The build still succeeds without it.
ARG SENTRY_AUTH_TOKEN
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
+22
View File
@@ -0,0 +1,22 @@
PROPRIETARY SOFTWARE LICENSE
Copyright (c) 2026 Property Management Network. All rights reserved.
This software and its source code (the "Software") are proprietary and
confidential. The Software is licensed, not sold.
No permission is granted to any person or entity to use, copy, reproduce,
modify, merge, publish, distribute, sublicense, sell, or create derivative
works of the Software, in whole or in part, by any means, without the prior
express written consent of the copyright holder.
Unauthorized copying, distribution, or use of the Software, via any medium,
is strictly prohibited.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF,
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+132 -135
View File
@@ -5,64 +5,99 @@
</picture>
</p>
# Property Management Network
<h1 align="center">🏠 Property Management Network</h1>
**Property management SaaS for independent landlords.** Track properties, tenants, rent, maintenance, leases, and expenses — all in one clean dashboard.
<p align="center">
<strong>The all-in-one property-management platform for independent landlords.</strong><br>
Properties, tenants, rent, maintenance, leases, expenses, AI insights, and integrations — in one clean dashboard.
</p>
Built with Next.js 16, PostgreSQL (Drizzle ORM), Better Auth, Stripe, and OpenAI. Deploys to DigitalOcean App Platform (see [DIGITALOCEAN.md](DIGITALOCEAN.md)).
<p align="center">
<img alt="Next.js" src="https://img.shields.io/badge/Next.js-16-black?logo=nextdotjs">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white">
<img alt="PostgreSQL" src="https://img.shields.io/badge/PostgreSQL-Drizzle_ORM-4169E1?logo=postgresql&logoColor=white">
<img alt="License" src="https://img.shields.io/badge/license-Proprietary-red">
</p>
---
## What it does
## ✨ Overview
Property Management Network replaces the spreadsheet + WhatsApp chaos that most small landlords live with. Key capabilities:
Property Management Network replaces the spreadsheet-and-WhatsApp chaos that most small landlords live with. It gives a solo landlord or a small team a single source of truth for their whole portfolio — and the automation, AI, and integrations to run it hands-off.
- **Properties & units** — manage your entire portfolio with occupancy tracking
- **Tenant profiles** — contact info, lease history, payment records, and a private tenant portal
- **Rent tracking** — log payments, send Stripe payment links, auto-mark overdue balances
- **Maintenance requests** — status workflow (Open → In Progress → Resolved), tenant submissions via portal
- **Lease management** — expiry countdowns, automated 60/30/7-day email alerts
- **Expenses** — categorized logging with recurring expense support
- **Documents** — file vault per property with drag-and-drop upload to local disk, served through an auth-gated route
- **AI features** — AI-powered recommendations, predictions, and impact tracking (Pro+)
- **Automated emails** — rent reminders, overdue alerts, lease expiry notifications via SMTP (SMTP2GO)
- **Tenant portal** — token-based (no login), tenants can view rent history and submit maintenance
Everything is **multi-tenant and team-aware**: each landlord operates on their own isolated portfolio, and Landlord/Lifetime accounts can invite teammates with scoped roles.
### 🧰 What you can do
**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** 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.
- 🗂️ **Documents** — a per-property file vault stored in object storage and served through an auth-gated route.
- 🔎 **Inspections & vendors** — move-in/out/routine inspection checklists and a vendor directory.
- 📊 **Reports & exports** — portfolio analytics with CSV export.
- 📅 **Calendar** — an in-app calendar plus a read-only **iCal (ICS) feed** you can subscribe to.
**Automation & AI**
- 🤖 **AI features** — recommendations, predictions, impact tracking, and a portfolio assistant (OpenAI). *(Pro and up.)*
- ✉️ **Automated email** — rent reminders, overdue notices, and lease-expiry alerts, plus a configurable **follow-up engine**.
- 🎨 **White-label branding** — put your own brand on the tenant portal. *(Landlord / Lifetime.)*
- 🛡️ **Admin dashboard** — superadmin tools with a full audit log.
---
## Revenue model
## 🔌 Integrations & developer platform
| Plan | Price | Limits |
|------|-------|--------|
| Starter | Free | 1 property, 3 tenants, no AI |
| Pro | $29/mo | 10 properties, unlimited tenants, AI (50 calls/mo) |
| Landlord | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) |
| Lifetime | $199 one-time | Everything in Landlord, forever |
| Capability | Details |
|---|---|
| 🌐 **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). |
| 📚 **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). |
Subscription billing via Stripe. Lifetime deal is ideal for Flippa buyers who want to offer an LTD to early customers.
Every integration is env-gated: unconfigured providers show a clean “not configured” state instead of a broken button.
---
## Tech stack
## 💳 Plans & pricing
| Layer | Tech |
|-------|------|
| Framework | Next.js 16.2 (App Router, TypeScript) |
| Styling | Tailwind CSS + Geist font |
| Database | PostgreSQL (via Drizzle ORM) |
| Plan | Price | Highlights |
|------|-------|------------|
| 🆓 **Starter** | Free | 1 property, 3 tenants, no AI |
| 🚀 **Pro** | $29/mo | 10 properties, unlimited tenants, AI (50 calls/mo) |
| 🏆 **Landlord** | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) |
| ♾️ **Lifetime** | $199 once | Everything in Landlord, forever |
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.
---
## 🧱 Tech stack
| Layer | Technology |
|-------|------------|
| Framework | Next.js 16.2 (App Router, TypeScript, React 19) |
| Styling | Tailwind CSS + Geist |
| Database | PostgreSQL via **Drizzle ORM** |
| Auth | Better Auth (email/password + Google OAuth) |
| Storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) |
| Payments | Stripe (subscriptions + payment links) |
| AI | OpenAI (gpt-4o-mini) |
| Object storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) |
| Payments | Stripe |
| AI | OpenAI (`gpt-4o-mini`) |
| Email | SMTP (SMTP2GO) |
| Maps | Leaflet + OpenStreetMap / Nominatim geocoding |
| Cron | DigitalOcean Functions (scheduled triggers) |
| Deploy | DigitalOcean App Platform (Docker image via DOCR) |
---
## Setup
## 🚀 Getting started
### 1. Clone and install
### 1. Clone & install
```bash
git clone <your-repo>
@@ -70,155 +105,117 @@ cd property-management-network
npm install
```
### 2. Configure environment variables
### 2. ⚙️ Configure environment
Copy the template and fill in your own values:
```bash
cp .env.example .env.local
```
Fill in `.env.local`:
`.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.
```env
# Database (PostgreSQL via Drizzle ORM)
DATABASE_URL=
### 3. 🗄️ Run migrations
# Auth (Better Auth)
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-random-secret-string
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# File storage (local disk)
STORAGE_DIR=./storage
# Stripe (no price IDs needed — resolved by lookup key, auto-created on first checkout)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# OpenAI
OPENAI_API_KEY=
# Email (SMTP — e.g. SMTP2GO)
SMTP_HOST=mail.smtp2go.com
SMTP_PORT=2525
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=postmaster@yourdomain.com
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
CRON_SECRET=your-random-secret-string
```
### 3. Run database migrations
The schema is managed with Drizzle ORM (see `drizzle.config.ts`). Point `DATABASE_URL` at your PostgreSQL instance in `.env.local`, then apply the migrations from `lib/db/migrations`:
The schema is managed by Drizzle (see `drizzle.config.ts`). Point `DATABASE_URL` at your PostgreSQL instance, then:
```bash
npm run db:migrate
npm run db:migrate # apply migrations
npm run db:generate # regenerate after schema changes
npm run db:push # push schema directly (quick local prototyping)
```
To regenerate migrations after changing the schema, use `npm run db:generate`. For quick local prototyping you can push the schema directly with `npm run db:push`.
### 4. 🔌 Wire up services (as needed)
### 4. Configure Stripe
- **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 / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present.
Add your API keys (`STRIPE_SECRET_KEY`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`) — that's it. Products and prices are resolved by stable **lookup keys** and auto-created on first checkout (Pro $29/mo, Landlord $59/mo, Lifetime $199, plus annual), so there are **no price IDs to configure** and going live is just an API-key swap. To pre-create the catalog, optionally run `node scripts/stripe-setup.mjs`.
Set up a webhook at `https://yourdomain.com/api/stripe/webhook` listening to:
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
- `payment_intent.succeeded`
### 5. Configure email (SMTP)
Use any SMTP provider (e.g. SMTP2GO). Verify your sending domain with the provider, then set `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, and `EMAIL_FROM`.
### 6. (Optional) Google OAuth
Create OAuth credentials in the Google Cloud Console and set `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` to enable Google sign-in via Better Auth.
### 7. Run locally
### 5. ▶️ Run locally
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
Open **[http://localhost:3000](http://localhost:3000)**.
### 8. Deploy (DigitalOcean App Platform)
### 6. 🚢 Deploy
The repo ships a production `Dockerfile` (Next.js standalone output), an App Platform spec at [`.do/app.yaml`](.do/app.yaml), DO Functions cron under [`functions/`](functions/), and a `/api/health` liveness probe. See **[DIGITALOCEAN.md](DIGITALOCEAN.md)** for the full walkthrough: build/push the image to DOCR, create the app, wire up Managed Postgres + Spaces, and deploy the scheduled cron functions.
The repo ships a production `Dockerfile` (Next.js standalone), an App Platform spec at [`.do/app.yaml`](.do/app.yaml), DO Functions cron under [`functions/`](functions/), and a `/api/health` probe. Follow **[DIGITALOCEAN.md](DIGITALOCEAN.md)** for the full walkthrough.
---
## Project structure
## 🗂️ Project structure
```
app/
├── (marketing)/ # Landing page, pricing, legal
├── (marketing)/ # Landing page, pricing, legal, API docs
├── (auth)/ # Login, signup, password reset
├── (dashboard)/ # All dashboard pages (auth-gated)
├── dashboard/ # Overview + stats
├── properties/ # Property + unit management
│ ├── tenants/ # Tenant profiles
│ ├── rent/ # Payment tracking
│ ├── maintenance/ # Maintenance requests
│ ├── leases/ # Lease tracking
│ ├── expenses/ # Expense logging
│ └── settings/ # Billing + profile
├── (dashboard)/ # Auth-gated app (properties, tenants, rent, maintenance,
# leases, expenses, inspections, vendors, reports,
# calendar, AI, onboarding, settings)
├── (admin)/ # Superadmin dashboard
├── api/
│ ├── properties/ # CRUD
│ ├── tenants/ # CRUD + auto unit assignment
│ ├── rent/ # CRUD + Stripe payment links
│ ├── maintenance/ # CRUD + status workflow
│ ├── leases/ # CRUD
── expenses/ # CRUD
│ ├── documents/ # Document metadata (files on local disk)
│ ├── ai/ # Rent receipts + maintenance summaries
│ ├── notifications/ # Send emails via SMTP (SMTP2GO)
│ ├── stripe/ # Checkout, portal, webhook
│ └── cron/ # Rent reminders + lease expiry alerts
│ ├── v1/ # 🌐 Public REST API (Bearer API keys)
│ ├── webhooks + cron/ # 🪝 Outbound webhook delivery + scheduled jobs
│ ├── stripe/ # 💳 Billing + payment links + provider webhooks
│ ├── integrations/ # 📚 QuickBooks / Xero OAuth
│ ├── esign/ # ✍️ DocuSign / Dropbox Sign
── # Properties, tenants, rent, maintenance, documents, AI
└── tenant-portal/[token]/ # Public tenant portal (no login)
lib/
├── db/ # Drizzle schema, queries, migrations
├── auth.ts # Better Auth config
├── storage.ts # Local-disk file storage helpers
├── stripe/ # Client, plans, payment links
├── 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/ # Billing clients & plans
├── accounting/ esign/ # QuickBooks/Xero & DocuSign/Dropbox Sign
├── ai/ # OpenAI client + prompts
├── email/ # SMTP (SMTP2GO) client + HTML templates
└── validations/ # Zod schemas for all entities
drizzle.config.ts # Drizzle ORM config (DATABASE_URL, migrations dir)
```
---
## Database schema
## 🗄️ Data model & isolation
11 tables, managed via Drizzle ORM:
The schema spans **~30 tables** managed via Drizzle ORM, grouped roughly as:
`profiles` · `properties` · `units` · `tenants` · `rent_payments` · `maintenance_requests` · `leases` · `expenses` · `documents` · `notifications` · `usage_events`
- **Core** — `profiles`, `properties`, `units`, `tenants`, `rent_payments`, `maintenance_requests`, `leases`, `expenses`, `documents`, `inspections`, `vendors`
- **Automation & AI** — `notifications`, `follow_up_rules`, `follow_up_log`, `ai_recommendations`, `ai_predictions`, `activity_log`, `usage_events`
- **Accounts & platform** — `account_members`, `api_keys`, `app_settings`, `admin_audit_log`, `accounting_connections`, `signature_requests`, `webhook_endpoints`, `webhook_deliveries`
- **Auth (Better Auth)** — `user`, `session`, `account`, `verification`
Data isolation is enforced in the application layer: every API route authenticates via `getSessionUser()` and scopes its queries by `user_id`. There is no database-level RLS, so this query scoping must be maintained carefully on every new route and query.
> 🔐 **Tenancy is enforced in the application layer.** Every query scopes by the resolved **account owner id** (team-aware), never the raw session user. There is no database RLS, so this scoping must be preserved on every new route — see `lib/account.ts` (`getEffectiveOwnerId`).
---
## Cron jobs
## ⏰ Scheduled jobs
| Job | Schedule | What it does |
|-----|----------|--------------|
| Rent reminders | Daily 9am UTC | Marks overdue payments, sends 3-day reminder emails |
| Lease expiry | Daily 10am UTC | Sends 60/30/7-day expiry alerts to landlord |
Cron is driven by DigitalOcean Functions hitting `CRON_SECRET`-protected endpoints (`functions/project.yml`):
Cron routes are protected with `CRON_SECRET` (Bearer token in `Authorization` header).
| Job | Schedule (UTC) | What it does |
|-----|----------------|--------------|
| `daily` | 09:00 | Rent reminders, overdue marking, 60/30/7-day lease-expiry alerts |
| `late-fees` | 08:00 | Applies late fees past the grace period |
| `follow-ups` | 10:00 | Runs each account's active follow-up rules |
| `webhooks` | every 5 min | Retries pending outbound webhook deliveries |
---
## License
## 🔒 Security highlights
MIT
- 🔑 API keys are stored as SHA-256 hashes; the plaintext is shown once.
- 🪝 Webhook payloads are **HMAC-SHA256 signed** (`X-PMN-Signature`); endpoint URLs are **SSRF-guarded** (private/loopback/metadata ranges blocked).
- 📁 Uploaded files are served only through an auth-gated route; object storage is required in production (uploads **fail loud** rather than silently hit ephemeral disk).
- 🛢️ Verified TLS to Postgres in production (`DATABASE_SSL=require` + CA).
- ⏱️ Cron endpoints use a constant-time bearer check and fail closed.
---
## 📜 License
**Proprietary — © 2026 Property Management Network. All rights reserved.**
This source code is proprietary and confidential. No license or permission is granted to use, copy, modify, merge, publish, distribute, sublicense, or sell any part of it without the prior written consent of the copyright holder. See [LICENSE](LICENSE).
+16 -1
View File
@@ -1,6 +1,8 @@
import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries"
import { getMaintenanceMode } from "@/lib/settings"
import { aiProviderStatus } from "@/lib/ai/provider"
import { MaintenanceToggle } from "@/components/admin/maintenance-toggle"
import { AiProviderToggle } from "@/components/admin/ai-provider-toggle"
import { formatDate } from "@/lib/utils"
import { Settings, Database, Table2 } from "lucide-react"
@@ -13,10 +15,11 @@ function humanize(name: string): string {
}
export default async function AdminSystemPage() {
const [{ counts, cronLastRun }, env, maintenance] = await Promise.all([
const [{ counts, cronLastRun }, env, maintenance, aiProvider] = await Promise.all([
getSystemCounts(),
Promise.resolve(getEnvHealth()),
getMaintenanceMode(),
aiProviderStatus(),
])
return (
@@ -37,6 +40,18 @@ export default async function AdminSystemPage() {
/>
</div>
{/* AI provider selection */}
<div className="mb-5">
<AiProviderToggle
selected={aiProvider.selected}
effective={aiProvider.effective}
openaiConfigured={aiProvider.openaiConfigured}
anthropicConfigured={aiProvider.anthropicConfigured}
openaiModel={aiProvider.openaiModel}
anthropicModel={aiProvider.anthropicModel}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
{/* ── Environment configuration ───────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
+6
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next"
import Link from "next/link"
import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { resetPassword } from "@/app/actions/auth"
export const metadata: Metadata = {
title: "Reset password",
robots: { index: false, follow: true },
}
export default async function ForgotPasswordPage({
searchParams,
}: {
+11
View File
@@ -1,7 +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"
import { isGoogleConfigured } from "@/lib/auth"
export const metadata: Metadata = {
title: "Sign in",
robots: { index: false, follow: true },
}
export default async function LoginPage({
searchParams,
@@ -22,6 +29,8 @@ export default async function LoginPage({
</div>
<div className="rounded-xl border border-white/10 bg-[#111118] p-8">
{isGoogleConfigured() && (
<>
{/* Google OAuth */}
<form action={signInWithGoogle}>
<button
@@ -41,6 +50,8 @@ export default async function LoginPage({
<span className="bg-[#111118] px-3 text-white/40">or continue with email</span>
</div>
</div>
</>
)}
{/* Error / Success messages */}
{error && (
+5
View File
@@ -2,6 +2,7 @@ import Link from "next/link"
import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signUp, signInWithGoogle } from "@/app/actions/auth"
import { isGoogleConfigured } from "@/lib/auth"
export default async function SignupPage({
searchParams,
@@ -47,6 +48,8 @@ export default async function SignupPage({
</div>
<div className="rounded-xl border border-white/10 bg-[#111118] p-8">
{isGoogleConfigured() && (
<>
{/* Google OAuth */}
<form action={signInWithGoogle}>
<button
@@ -66,6 +69,8 @@ export default async function SignupPage({
<span className="bg-[#111118] px-3 text-white/40">or sign up with email</span>
</div>
</div>
</>
)}
{error && (
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
+13 -19
View File
@@ -4,13 +4,16 @@ import { db } from "@/lib/db"
import { leases as leasesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { listAdapters, listRequestsForLease } from "@/lib/esign"
import { listEsignConnections, listRequestsForLease } from "@/lib/esign"
import Link from "next/link"
import { FileText, ExternalLink } from "lucide-react"
import { FileText } from "lucide-react"
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
import { cn } from "@/lib/utils"
import { LeaseActions } from "@/components/forms/lease-actions"
import { EsignLease } from "@/components/forms/esign-lease"
import { LeaseDocument } from "@/components/forms/lease-document"
const ESIGN_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
export const metadata = { title: "Lease" }
@@ -56,8 +59,12 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
if (!lease) notFound()
const esignRequests = await listRequestsForLease(ownerId, leaseId)
const esignProviders = listAdapters()
const canSendEsign = ctx.canWrite && !!lease.document_url && !!lease.tenant?.email
const esignConnections = await listEsignConnections(ownerId)
const connectedProviders = esignConnections
.filter((c) => c.status !== "revoked")
.map((c) => ({ id: c.provider, label: ESIGN_LABEL[c.provider] ?? c.provider }))
const canSendEsign =
ctx.canWrite && !!lease.document_url && !!lease.tenant?.email && connectedProviders.length > 0
const esignDisabledReason = !ctx.canWrite
? "You have read-only access."
: !lease.document_url
@@ -196,24 +203,11 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
</div>
</div>
{lease.document_url && (
<a
href={lease.document_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-xl border border-white/[0.06] bg-[#16161f] p-5 transition hover:border-white/15"
>
<div className="flex items-center gap-2 text-sm font-medium text-white">
<FileText className="h-4 w-4 text-indigo-400" />
Lease document
</div>
<ExternalLink className="h-4 w-4 text-white/40" />
</a>
)}
<LeaseDocument leaseId={leaseId} documentUrl={lease.document_url} canWrite={ctx.canWrite} />
<EsignLease
leaseId={leaseId}
providers={esignProviders}
connected={connectedProviders}
requests={esignRequests}
canSend={canSendEsign}
disabledReason={esignDisabledReason}
+3 -19
View File
@@ -5,9 +5,7 @@ import { profiles, properties, tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { CheckoutButton } from "@/components/forms/checkout-button"
import { PortalButton } from "@/components/forms/portal-button"
import { PaypalCancelButton } from "@/components/forms/paypal-cancel-button"
import { getPlanLabel, PLAN_LIMITS, annualEnabled } from "@/lib/stripe/plans"
import { paypalConfigured } from "@/lib/paypal/client"
import { Check } from "lucide-react"
import type { Plan } from "@/types"
@@ -59,7 +57,7 @@ const PLANS = [
export default async function BillingPage({
searchParams,
}: {
searchParams: Promise<{ success?: string; canceled?: string; error?: string }>
searchParams: Promise<{ success?: string; canceled?: string }>
}) {
const user = await getSessionUser()
if (!user) redirect("/login")
@@ -72,16 +70,12 @@ export default async function BillingPage({
plan_expires_at: true,
stripe_customer_id: true,
stripe_subscription_id: true,
paypal_subscription_id: true,
billing_provider: true,
},
})
const params = await searchParams
const currentPlan = (profile?.plan ?? "starter") as Plan
const hasStripeAccount = !!profile?.stripe_customer_id
const isPaypal = profile?.billing_provider === "paypal" || !!profile?.paypal_subscription_id
const paypalEnabled = paypalConfigured()
const limits = PLAN_LIMITS[currentPlan]
const canBillAnnually = annualEnabled()
@@ -113,12 +107,6 @@ export default async function BillingPage({
Checkout canceled no charge was made.
</div>
)}
{params.error === "paypal" && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-5 py-4 text-sm text-red-400">
We couldn&apos;t complete your PayPal payment. No charge was made please try again.
</div>
)}
{/* Current plan */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between">
@@ -129,12 +117,9 @@ export default async function BillingPage({
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
)}
</div>
{currentPlan !== "starter" && currentPlan !== "lifetime" &&
(isPaypal ? (
<PaypalCancelButton />
) : hasStripeAccount ? (
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
<PortalButton />
) : null)}
)}
</div>
</div>
@@ -197,7 +182,6 @@ export default async function BillingPage({
label={plan.cta}
highlight={plan.highlight}
annualAvailable={canBillAnnually && plan.key !== "lifetime"}
paypalEnabled={paypalEnabled}
/>
)}
</div>
+29 -2
View File
@@ -2,7 +2,9 @@ import { redirect } from "next/navigation"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { listProviders, listConnections } from "@/lib/accounting"
import { listEsignAdapters, listEsignConnections } from "@/lib/esign"
import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations"
import { EsignIntegrations } from "@/components/dashboard/esign-integrations"
export const metadata = { title: "Integrations" }
export const dynamic = "force-dynamic"
@@ -20,10 +22,34 @@ export default async function IntegrationsPage({
const providers = listProviders()
const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : []
const esignAdapters = listEsignAdapters()
const esignConnections = ctx.isOwner ? await listEsignConnections(ctx.ownerId) : []
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
// DocuSign vs Dropbox Sign flash messages are keyed by provider id, so a single
// connected/error param drives whichever card the user just acted on.
const esignFlash = { connected: sp.connected, error: sp.error }
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="max-w-3xl mx-auto space-y-8">
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-white">Integrations</h2>
<h2 className="text-lg font-bold text-white">E-signature</h2>
<p className="text-sm text-white/40 mt-0.5">
Connect your own DocuSign or Dropbox Sign account to send leases for signature.
</p>
</div>
<EsignIntegrations
adapters={esignAdapters}
connections={esignConnections}
isOwner={ctx.isOwner}
flash={esignFlash}
webhookUrl={`${appUrl}/api/esign/dropbox_sign/webhook`}
/>
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-white">Accounting</h2>
<p className="text-sm text-white/40 mt-0.5">
Connect your accounting software to automatically push rent income and expenses into your books.
</p>
@@ -35,5 +61,6 @@ export default async function IntegrationsPage({
flash={{ connected: sp.connected, error: sp.error }}
/>
</div>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { redirect } from "next/navigation"
import Link from "next/link"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { account_deletion_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { LEGAL } from "@/lib/legal"
import { PrivacyManager } from "@/components/dashboard/privacy-manager"
export const metadata = { title: "Privacy & Data" }
export default async function PrivacySettingsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const pending = await db.query.account_deletion_requests.findFirst({
where: and(
eq(account_deletion_requests.user_id, user.id),
eq(account_deletion_requests.status, "pending")
),
})
return (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Privacy &amp; Data</h2>
<p className="text-sm text-white/40">
Exercise your data rights under the GDPR export a copy of your data or delete your
account. Details are in our{" "}
<Link
href="/gdpr"
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
>
GDPR &amp; Data Rights
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
>
Privacy Policy
</Link>{" "}
pages.
</p>
</div>
<PrivacyManager
accountEmail={user.email}
graceDays={LEGAL.dataDeletionDays}
pendingDeletion={
pending ? { scheduled_for: pending.scheduled_for, created_at: pending.created_at } : null
}
/>
</div>
)
}
+21 -1
View File
@@ -134,7 +134,27 @@ export default function GdprPage() {
<Section id="exercise" heading="8. How to exercise your rights">
<p>
To exercise any of the rights described above, contact us at{" "}
You can exercise the most common rights yourself, instantly, from{" "}
<strong>Settings &rarr; Privacy &amp; Data</strong> in your dashboard:
</p>
<ul>
<li>
<strong>Access &amp; portability</strong> &mdash; download a complete,
machine-readable JSON export of your personal data and portfolio records.
</li>
<li>
<strong>Erasure</strong> &mdash; delete your account. Deletion is scheduled{" "}
{LEGAL.dataDeletionDays} days out (during which you can cancel), after which your
account, data, and uploaded files are permanently erased and any active
subscription is cancelled.
</li>
<li>
<strong>Rectification</strong> &mdash; correct your details at any time in{" "}
<strong>Settings &rarr; Profile</strong>.
</li>
</ul>
<p>
For any other request, contact us at{" "}
<a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For
data-protection matters, you may also contact our data-protection team at{" "}
<a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>. You also have the right
+25 -4
View File
@@ -8,9 +8,11 @@ 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"
import { executeAccountDeletion } from "@/lib/gdpr/delete"
// ── gate ────────────────────────────────────────────────────────────────────
// Every server action re-verifies the caller is an admin. NEVER skip — these
@@ -108,15 +110,15 @@ export async function deleteUser(userId: string) {
const a = await guard()
if (userId === a.user.id) throw new Error("You cannot delete yourself")
await auth.api.removeUser({
body: { userId },
headers: await headers(),
})
// Full GDPR-grade purge: cancels Stripe billing, deletes stored files, and
// removes the user row (FK cascade erases the whole portfolio + sessions).
const outcome = await executeAccountDeletion(userId)
await logAdminAction({
adminId: a.user.id,
action: "delete_user",
targetUserId: userId,
metadata: { ...outcome },
})
redirect("/admin/users")
@@ -158,3 +160,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 }
}
+12 -2
View File
@@ -3,7 +3,7 @@
import { redirect } from "next/navigation"
import { headers } from "next/headers"
import { APIError } from "better-auth/api"
import { auth } from "@/lib/auth"
import { auth, isGoogleConfigured } from "@/lib/auth"
import { verifyTurnstile } from "@/lib/turnstile"
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
@@ -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)}`)
}
@@ -72,6 +77,11 @@ export async function signIn(formData: FormData) {
}
export async function signInWithGoogle() {
// Defense in depth: the auth pages hide the Google button when it isn't
// configured, but guard the action too in case it's POSTed directly.
if (!isGoogleConfigured()) {
redirect(`/login?error=${encodeURIComponent("Google sign-in isn't available right now.")}`)
}
let url: string | undefined
try {
const res = await auth.api.signInSocial({
+63 -1
View File
@@ -1,9 +1,27 @@
"use server"
import { revalidatePath } from "next/cache"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { sendLeaseForSignature, getAdapter, type ESignProvider } from "@/lib/esign"
import { keyBelongsToOwner } from "@/lib/storage"
import {
sendLeaseForSignature,
getAdapter,
saveEsignConnection,
disconnectEsign,
type ESignProvider,
} from "@/lib/esign"
async function ownerGuard() {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) throw new Error("Only the account owner can manage integrations")
return ctx
}
export async function sendLeaseForSignatureAction(leaseId: string, provider: string) {
const user = await getSessionUser()
@@ -15,3 +33,47 @@ export async function sendLeaseForSignatureAction(leaseId: string, provider: str
revalidatePath(`/leases/${leaseId}`)
return { ok: true }
}
/** Connect Dropbox Sign by validating and storing the landlord's API key. */
export async function connectDropboxSign(apiKey: string) {
const ctx = await ownerGuard()
const adapter = getAdapter("dropbox_sign")
if (!adapter) throw new Error("Unknown provider")
const tokens = await adapter.connectApiKey(typeof apiKey === "string" ? apiKey : "")
await saveEsignConnection(ctx.ownerId, "dropbox_sign", tokens)
revalidatePath("/settings/integrations")
return { ok: true, accountName: tokens.accountName }
}
export async function disconnectEsignAction(provider: string) {
const ctx = await ownerGuard()
if (!getAdapter(provider)) throw new Error("Unknown provider")
await disconnectEsign(ctx.ownerId, provider as ESignProvider)
revalidatePath("/settings/integrations")
return { ok: true }
}
/**
* Attach an already-uploaded document (via /api/upload) to a lease. Validates
* the file belongs to the caller's namespace to prevent cross-tenant refs.
*/
export async function setLeaseDocument(leaseId: string, fileUrl: string) {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) throw new Error("You don't have permission to do that")
const prefix = "/api/files/"
if (typeof fileUrl !== "string" || !fileUrl.startsWith(prefix)) throw new Error("Invalid document reference")
if (!keyBelongsToOwner(fileUrl.slice(prefix.length), ctx.ownerId)) throw new Error("Invalid document reference")
const [row] = await db
.update(leases)
.set({ document_url: fileUrl })
.where(and(eq(leases.id, leaseId), eq(leases.user_id, ctx.ownerId)))
.returning({ id: leases.id })
if (!row) throw new Error("Lease not found")
revalidatePath(`/leases/${leaseId}`)
return { ok: true, url: fileUrl }
}
+109
View File
@@ -0,0 +1,109 @@
"use server"
import { revalidatePath } from "next/cache"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { account_deletion_requests } from "@/lib/db/schema"
import { getSessionUser, isAdminUser } from "@/lib/session"
import { LEGAL } from "@/lib/legal"
import { sendEmail, accountDeletionRequestedHtml } from "@/lib/email/send"
// ============================================================================
// GDPR self-service actions (Settings → Privacy & Data).
//
// Deletion is a two-step, grace-period flow: the request schedules a hard
// delete LEGAL.dataDeletionDays out (the retention window promised on /gdpr);
// the gdpr cron executes it. Until then the account stays usable and the user
// can cancel. Data export is a GET route (/api/gdpr/export), not an action,
// so the browser can download it as a file.
// ============================================================================
const SETTINGS_PATH = "/settings/privacy"
export type DeletionRequestDTO = {
id: string
status: "pending" | "cancelled" | "completed"
scheduled_for: string
created_at: string
}
export async function requestAccountDeletion(input: {
confirmEmail: string
reason?: string
}): Promise<DeletionRequestDTO> {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
// Admins manage the platform — deleting one from self-service risks locking
// everyone out. They can be removed via the admin panel by another admin.
if (isAdminUser(user as { id?: string; email?: string; role?: string | null })) {
throw new Error("Admin accounts cannot be deleted from self-service. Contact another administrator.")
}
const typed = (input.confirmEmail ?? "").trim().toLowerCase()
if (!typed || typed !== user.email.toLowerCase()) {
throw new Error("The email you typed doesn't match your account email.")
}
const existing = await db.query.account_deletion_requests.findFirst({
where: and(
eq(account_deletion_requests.user_id, user.id),
eq(account_deletion_requests.status, "pending")
),
})
if (existing) throw new Error("Your account is already scheduled for deletion.")
const scheduledFor = new Date(Date.now() + LEGAL.dataDeletionDays * 24 * 60 * 60 * 1000).toISOString()
let row: typeof account_deletion_requests.$inferSelect
try {
;[row] = await db
.insert(account_deletion_requests)
.values({
user_id: user.id,
email: user.email,
reason: (input.reason ?? "").trim().slice(0, 500) || null,
scheduled_for: scheduledFor,
})
.returning()
} catch {
// The partial unique index makes a double-submit race land here.
throw new Error("Your account is already scheduled for deletion.")
}
await sendEmail({
to: user.email,
subject: "Your account deletion is scheduled",
html: accountDeletionRequestedHtml({
name: user.name || user.email,
scheduledDate: new Date(scheduledFor).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
graceDays: LEGAL.dataDeletionDays,
}),
})
revalidatePath(SETTINGS_PATH)
return { id: row.id, status: row.status, scheduled_for: row.scheduled_for, created_at: row.created_at }
}
export async function cancelAccountDeletion(): Promise<void> {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const [row] = await db
.update(account_deletion_requests)
.set({ status: "cancelled", cancelled_at: new Date().toISOString() })
.where(
and(
eq(account_deletion_requests.user_id, user.id),
eq(account_deletion_requests.status, "pending")
)
)
.returning({ id: account_deletion_requests.id })
if (!row) throw new Error("No pending deletion request found.")
revalidatePath(SETTINGS_PATH)
}
+16 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -21,6 +22,9 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_ask")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -154,16 +158,22 @@ ${dataBlock("EXPIRING LEASES", JSON.stringify(expiringLeases, null, 2))}
Answer the landlord's question in a helpful, concise, and professional manner. Use bullet points where appropriate. Be specific with numbers from the data above. If the question is unrelated to property management, politely redirect.
`
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1024,
let answer: string
try {
answer = await aiComplete({
messages: [
{ role: "system", content: context },
{ role: "user", content: question },
],
maxTokens: 1024,
})
const answer = completion.choices[0].message.content ?? ""
} catch (err) {
console.error("[ai/ask] AI request failed:", err)
return NextResponse.json(
{ error: "The AI service is temporarily unavailable. Please try again in a moment." },
{ status: 502 }
)
}
return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } })
}
+9 -7
View File
@@ -4,7 +4,8 @@ import { db } from "@/lib/db"
import { properties, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -12,6 +13,9 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_maintenance_summary")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -39,9 +43,7 @@ export async function POST(request: Request) {
columns: { name: true },
})
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1024,
const text = await aiComplete({
messages: [
{ role: "system", content: MAINTENANCE_SUMMARY_PROMPT },
{
@@ -49,13 +51,13 @@ export async function POST(request: Request) {
content: `${dataBlock("PROPERTY NAME", property?.name ?? "Unknown")}\n\n${dataBlock("MAINTENANCE REQUESTS", JSON.stringify(requests, null, 2))}`,
},
],
maxTokens: 1024,
json: true,
})
const text = completion.choices[0].message.content ?? ""
let summary
try {
summary = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim())
summary = JSON.parse(text)
} catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
}
+9 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -38,6 +39,9 @@ export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_predictions")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -174,16 +178,15 @@ Generate a JSON object with key "predictions" containing an array of 5-7 predict
Only return valid JSON, no other text.`
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 2000,
const content = await aiComplete({
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
maxTokens: 2000,
json: true,
})
let predictions: any[] = []
try {
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}")
const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
} catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
+9 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -37,6 +38,9 @@ export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_recommendations")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -167,13 +171,12 @@ Only return valid JSON, no other text.`
let recommendations: any[] = []
try {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1500,
const content = await aiComplete({
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
maxTokens: 1500,
json: true,
})
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}")
const parsed = JSON.parse(content || "{}")
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
} catch (err: any) {
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 })
+9 -7
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { getSessionUser } from "@/lib/session"
import { openai } from "@/lib/ai/client"
import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { RENT_RECEIPT_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -25,6 +26,9 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_rent_receipt")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -36,9 +40,7 @@ export async function POST(request: Request) {
// Pass only the whitelisted, validated fields to the model.
const { payment_id, ...receiptFields } = parsed.data
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
max_tokens: 1024,
const text = await aiComplete({
messages: [
{ role: "system", content: RENT_RECEIPT_PROMPT },
{
@@ -46,13 +48,13 @@ export async function POST(request: Request) {
content: dataBlock("PAYMENT DETAILS", JSON.stringify(receiptFields, null, 2)),
},
],
maxTokens: 1024,
json: true,
})
const text = completion.choices[0].message.content ?? ""
let receipt
try {
receipt = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim())
receipt = JSON.parse(text)
} catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server"
import { isAuthorizedCron } from "@/lib/cron-auth"
import { processDueDeletions } from "@/lib/gdpr/delete"
// GDPR deletion drain: hard-deletes accounts whose grace period
// (LEGAL.dataDeletionDays after the request) has elapsed. Scheduled daily via
// DigitalOcean Functions — see functions/project.yml.
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { processed, deleted } = await processDueDeletions(25)
return NextResponse.json({ processed, deleted })
}
+1 -1
View File
@@ -44,7 +44,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
if (doc.storage_path) {
await deleteFile(doc.storage_path)
await deleteFile(doc.storage_path, ownerId)
}
return NextResponse.json({ success: true })
+28 -3
View File
@@ -3,7 +3,14 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { documents, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import {
saveFile,
isAllowedUploadExt,
StorageNotConfiguredError,
keyBelongsToOwner,
contentMatchesExtension,
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
@@ -57,6 +64,10 @@ export async function POST(request: Request) {
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 })
if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
if (!contentMatchesExtension(head, extOf(file.name))) {
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
}
const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
@@ -113,6 +124,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 })
}
// The file reference is client-supplied. Require it to be an /api/files URL
// inside the caller's OWN namespace, and derive storage_path from it — never
// trust a separate client storage_path (which could point at another tenant's
// object and later be deleted). Also blocks javascript:/external file_url values.
const FILES_PREFIX = "/api/files/"
const fileUrl = typeof body.file_url === "string" ? body.file_url : ""
if (!fileUrl.startsWith(FILES_PREFIX)) {
return NextResponse.json({ error: "file_url must reference an uploaded file" }, { status: 400 })
}
const storagePath = fileUrl.slice(FILES_PREFIX.length)
if (!keyBelongsToOwner(storagePath, ownerId)) {
return NextResponse.json({ error: "Invalid file reference" }, { status: 403 })
}
// Whitelist insertable columns — never trust client-supplied user_id/id/created_at.
const [data] = await db
.insert(documents)
@@ -122,8 +147,8 @@ export async function POST(request: Request) {
tenant_id: tenantId,
name: body.name as string,
category: (body.category as typeof documents.$inferInsert.category) ?? "other",
file_url: body.file_url as string,
storage_path: body.storage_path as string | undefined,
file_url: fileUrl,
storage_path: storagePath,
file_type: body.file_type as string | undefined,
file_size: body.file_size as number | undefined,
})
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getAdapter, saveEsignConnection, type ESignProvider } from "@/lib/esign"
import { verifyState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state"
// OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params
const adapter = getAdapter(provider)
const settings = new URL("/settings/integrations", request.url)
const cookieStore = await cookies()
const nonceCookie = cookieStore.get(ESIGN_NONCE_COOKIE)?.value
const done = (p: Record<string, string>) => {
for (const [k, v] of Object.entries(p)) settings.searchParams.set(k, v)
const res = NextResponse.redirect(settings)
res.cookies.set(ESIGN_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
return res
}
const url = new URL(request.url)
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const oauthError = url.searchParams.get("error")
if (oauthError || !adapter || adapter.kind !== "oauth") return done({ error: "connect_failed" })
const st = state ? verifyState(state) : null
// CSRF: state nonce must match the cookie, and the session must be the same owner.
if (!code || !st || st.provider !== provider || !nonceCookie || nonceCookie !== st.nonce) {
return done({ error: "invalid_state" })
}
const user = await getSessionUser()
if (!user) return done({ error: "invalid_state" })
const ctx = await getAccountContext(user.id)
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
try {
const tokens = await adapter.exchangeCode(code)
if (!tokens.accountId || !tokens.baseUri) throw new Error("No account returned from provider")
await saveEsignConnection(st.ownerId, provider as ESignProvider, tokens)
return done({ connected: provider })
} catch {
return done({ error: "connect_failed" })
}
}
+49
View File
@@ -0,0 +1,49 @@
import crypto from "crypto"
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getAdapter } from "@/lib/esign"
import { signState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state"
// Starts the OAuth connect flow for an e-signature provider (owner-only).
// API-key providers (Dropbox Sign) don't use this — they connect via a form.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params
const adapter = getAdapter(provider)
const settings = new URL("/settings/integrations", request.url)
if (!adapter) {
settings.searchParams.set("error", "unknown_provider")
return NextResponse.redirect(settings)
}
const user = await getSessionUser()
if (!user) return NextResponse.redirect(new URL("/login", request.url))
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) {
settings.searchParams.set("error", "owner_only")
return NextResponse.redirect(settings)
}
if (adapter.kind !== "oauth") {
settings.searchParams.set("error", "use_api_key")
return NextResponse.redirect(settings)
}
if (!adapter.available()) {
settings.searchParams.set("error", "not_configured")
return NextResponse.redirect(settings)
}
// Bind the round-trip to this browser: nonce in the signed state AND a cookie.
const nonce = crypto.randomUUID()
const state = signState({ ownerId: ctx.ownerId, provider, nonce })
const res = NextResponse.redirect(adapter.getAuthUrl(state))
res.cookies.set(ESIGN_NONCE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
})
return res
}
+5 -3
View File
@@ -1,15 +1,17 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { getAccountContext } from "@/lib/account"
import { runFollowUpsForUser } from "@/lib/follow-ups"
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
// Sends real outbound follow-ups — a mutating action, so viewers are blocked.
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const result = await runFollowUpsForUser(ownerId)
const result = await runFollowUpsForUser(ctx.ownerId)
// Preserve the original response shape ({ sent, results }). The detailed
// per-follow-up rows now live only in follow_up_log; the client re-fetches
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server"
import { headers } from "next/headers"
import { db } from "@/lib/db"
import { consent_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { LEGAL } from "@/lib/legal"
// Records a cookie-consent choice from the banner. Only signed-in users are
// logged — anonymous visitors keep their choice in localStorage only, so this
// endpoint can't be used to spam the consent table.
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return new NextResponse(null, { status: 204 })
let analytics = false
try {
const body = await request.json()
analytics = body?.analytics === true
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 })
}
const h = await headers()
await db.insert(consent_log).values({
user_id: user.id,
email: user.email,
kind: "cookies",
granted: analytics,
policy_version: LEGAL.lastUpdated,
source: "cookie-banner",
ip_address: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip"),
user_agent: h.get("user-agent"),
})
return NextResponse.json({ ok: true })
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server"
import { db } from "@/lib/db"
import { usage_events } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { buildUserDataExport } from "@/lib/gdpr/export"
// GDPR data export (Articles 15/20) — downloads everything the platform stores
// about the signed-in user as a single JSON file. Sensitive credentials are
// excluded by the builder (see lib/gdpr/export.ts).
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const data = await buildUserDataExport(user.id)
// DSAR evidence: record that the export was served.
await db.insert(usage_events).values({ user_id: user.id, event_type: "gdpr_data_export" })
const filename = `pmn-data-export-${new Date().toISOString().slice(0, 10)}.json`
return new NextResponse(JSON.stringify(data, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
})
}
@@ -1,37 +1,51 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getProvider, saveConnection, type Provider } from "@/lib/accounting"
import { verifyState } from "@/lib/accounting/state"
import { verifyState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
// OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider: pid } = await params
const prov = getProvider(pid)
const url = new URL(request.url)
const settings = new URL("/settings/integrations", request.url)
// Always clear the one-shot nonce cookie on the way out.
const cookieStore = await cookies()
const nonceCookie = cookieStore.get(OAUTH_NONCE_COOKIE)?.value
const done = (params: Record<string, string>) => {
for (const [k, v] of Object.entries(params)) settings.searchParams.set(k, v)
const res = NextResponse.redirect(settings)
res.cookies.set(OAUTH_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
return res
}
const url = new URL(request.url)
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const realmId = url.searchParams.get("realmId") // QuickBooks includes this
const oauthError = url.searchParams.get("error")
if (oauthError || !prov) {
settings.searchParams.set("error", "connect_failed")
return NextResponse.redirect(settings)
}
if (oauthError || !prov) return done({ error: "connect_failed" })
const st = state ? verifyState(state) : null
if (!code || !st || st.provider !== pid) {
settings.searchParams.set("error", "invalid_state")
return NextResponse.redirect(settings)
// CSRF: the state's nonce must match the cookie set at connect time, and the
// current session must be the same owner that initiated the connect.
if (!code || !st || st.provider !== pid || !nonceCookie || nonceCookie !== st.nonce) {
return done({ error: "invalid_state" })
}
const user = await getSessionUser()
if (!user) return done({ error: "invalid_state" })
const ctx = await getAccountContext(user.id)
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
try {
const tokens = await prov.exchangeCode(code, realmId)
if (!tokens.realmId) throw new Error("No organisation returned from provider")
await saveConnection(st.ownerId, pid as Provider, tokens)
settings.searchParams.set("connected", pid)
return done({ connected: pid })
} catch {
settings.searchParams.set("error", "connect_failed")
return done({ error: "connect_failed" })
}
return NextResponse.redirect(settings)
}
@@ -1,8 +1,9 @@
import crypto from "crypto"
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getProvider } from "@/lib/accounting"
import { signState } from "@/lib/accounting/state"
import { signState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
// Starts the OAuth connect flow for an accounting provider (owner-only).
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
@@ -28,6 +29,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov
return NextResponse.redirect(settings)
}
const state = signState({ ownerId: ctx.ownerId, provider: pid })
return NextResponse.redirect(prov.getAuthUrl(state))
// Bind the OAuth round-trip to this browser: a random nonce goes into the
// signed state AND an httpOnly cookie; the callback requires them to match.
const nonce = crypto.randomUUID()
const state = signState({ ownerId: ctx.ownerId, provider: pid, nonce })
const res = NextResponse.redirect(prov.getAuthUrl(state))
res.cookies.set(OAUTH_NONCE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
})
return res
}
-32
View File
@@ -1,32 +0,0 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { cancelSubscription } from "@/lib/paypal/checkout"
// Cancel the signed-in user's PayPal subscription. The account keeps access
// until the paid period ends; the BILLING.SUBSCRIPTION.CANCELLED webhook does
// the final downgrade to starter.
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { paypal_subscription_id: true },
})
if (!profile?.paypal_subscription_id) {
return NextResponse.json({ error: "No PayPal subscription to cancel" }, { status: 400 })
}
const ok = await cancelSubscription(profile.paypal_subscription_id)
if (!ok) return NextResponse.json({ error: "PayPal cancellation failed" }, { status: 502 })
await db
.update(profiles)
.set({ subscription_status: "canceled" })
.where(eq(profiles.id, user.id))
return NextResponse.json({ ok: true })
}
-73
View File
@@ -1,73 +0,0 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { paypalConfigured } from "@/lib/paypal/client"
import { getPaypalPlanId } from "@/lib/paypal/plans"
import { createSubscription, createOrder } from "@/lib/paypal/checkout"
import { PLAN_AMOUNTS } from "@/lib/stripe/plans"
const RECURRING = new Set(["pro", "landlord"])
// Start a PayPal checkout for a plan upgrade and return the approval URL.
// Recurring plans → Subscriptions API; lifetime → one-time Orders API.
export async function POST(request: Request) {
if (!paypalConfigured()) {
return NextResponse.json({ error: "PayPal is not configured" }, { status: 400 })
}
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { plan, interval } = (await request.json().catch(() => ({}))) as {
plan?: string
interval?: "month" | "year"
}
if (!plan || (plan !== "lifetime" && !RECURRING.has(plan))) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 })
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const cancelUrl = `${appUrl}/settings/billing?canceled=true`
try {
if (plan === "lifetime") {
const { approveUrl } = await createOrder({
amount: PLAN_AMOUNTS.lifetime,
userId: user.id,
plan: "lifetime",
returnUrl: `${appUrl}/api/paypal/return?type=order`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
}
const billingInterval = interval === "year" ? "year" : "month"
const planId = getPaypalPlanId(plan as "pro" | "landlord", billingInterval)
if (!planId) {
return NextResponse.json({ error: "That plan isn't available on PayPal yet." }, { status: 400 })
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { email: true },
})
const { approveUrl } = await createSubscription({
planId,
userId: user.id,
plan,
email: profile?.email ?? user.email,
returnUrl: `${appUrl}/api/paypal/return?type=subscription`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message || "PayPal checkout failed" },
{ status: 502 }
)
}
}
-52
View File
@@ -1,52 +0,0 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { captureOrder, getSubscription, decodeCustomId } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime } from "@/lib/paypal/fulfill"
// PayPal redirects the approver back here. We finalize synchronously (capture
// the order / confirm the subscription) so the plan is live the moment they
// land on the billing page — the webhook is a backstop, not the only path.
export async function GET(request: Request) {
const url = new URL(request.url)
const type = url.searchParams.get("type")
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const ok = NextResponse.redirect(`${appUrl}/settings/billing?success=true`)
const fail = NextResponse.redirect(`${appUrl}/settings/billing?error=paypal`)
const user = await getSessionUser()
if (!user) return NextResponse.redirect(`${appUrl}/login`)
try {
if (type === "order") {
const orderId = url.searchParams.get("token")
if (!orderId) return fail
const captured = await captureOrder(orderId)
if (!captured || captured.status !== "COMPLETED") return fail
const decoded = decodeCustomId(captured.custom_id)
if (!decoded || decoded.userId !== user.id) return fail
await fulfillLifetime(user.id)
return ok
}
// Subscription approval.
const subId = url.searchParams.get("subscription_id")
if (!subId) return fail
const sub = await getSubscription(subId)
if (!sub) return fail
const decoded = decodeCustomId(sub.custom_id)
// Only accept a subscription whose custom_id matches the signed-in user.
if (!decoded || decoded.userId !== user.id) return fail
const active = sub.status === "ACTIVE" || sub.status === "APPROVED"
await fulfillSubscription(
user.id,
decoded.plan,
sub.id,
sub.billing_info?.next_billing_time,
active ? "active" : sub.status.toLowerCase()
)
return ok
} catch {
return fail
}
}
-89
View File
@@ -1,89 +0,0 @@
import { NextResponse } from "next/server"
import { verifyPaypalWebhook } from "@/lib/paypal/webhook"
import { decodeCustomId, getSubscription } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime, markPaypalSubscriptionInactive } from "@/lib/paypal/fulfill"
// Inbound PayPal webhook. Signature is verified via PayPal's API using
// PAYPAL_WEBHOOK_ID; unverified events are rejected.
export async function POST(request: Request) {
const body = await request.text()
const valid = await verifyPaypalWebhook(request.headers, body)
if (!valid) return NextResponse.json({ error: "invalid signature" }, { status: 400 })
let event: { event_type?: string; resource?: Record<string, unknown> }
try {
event = JSON.parse(body)
} catch {
return NextResponse.json({ ok: true })
}
const type = event.event_type ?? ""
const resource = (event.resource ?? {}) as Record<string, any>
try {
switch (type) {
case "BILLING.SUBSCRIPTION.ACTIVATED":
case "BILLING.SUBSCRIPTION.UPDATED": {
const decoded = decodeCustomId(resource.custom_id)
if (decoded && resource.id) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
resource.id,
resource.billing_info?.next_billing_time,
"active"
)
}
break
}
case "PAYMENT.SALE.COMPLETED": {
// A recurring payment cleared — refresh status + next billing date.
const subId = resource.billing_agreement_id as string | undefined
if (subId) {
const sub = await getSubscription(subId)
const decoded = decodeCustomId(sub?.custom_id)
if (sub && decoded) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
subId,
sub.billing_info?.next_billing_time,
"active"
)
}
}
break
}
case "BILLING.SUBSCRIPTION.CANCELLED":
case "BILLING.SUBSCRIPTION.EXPIRED": {
if (resource.id) {
await markPaypalSubscriptionInactive(
resource.id,
type.endsWith("CANCELLED") ? "canceled" : "expired",
true
)
}
break
}
case "BILLING.SUBSCRIPTION.SUSPENDED": {
if (resource.id) await markPaypalSubscriptionInactive(resource.id, "suspended", false)
break
}
case "PAYMENT.CAPTURE.COMPLETED": {
// Lifetime order capture (backup to the return handler).
const decoded = decodeCustomId(resource.custom_id)
if (decoded && decoded.plan === "lifetime") await fulfillLifetime(decoded.userId)
break
}
}
} catch {
// Never loop forever on a handler bug — PayPal retries non-2xx.
}
return NextResponse.json({ received: true })
}
+9
View File
@@ -8,8 +8,17 @@ export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Exclude bearer/secret + billing-id columns from the client payload. The
// calendar feed token and Stripe/PayPal ids are used server-side only.
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: {
calendar_token: false,
stripe_customer_id: false,
stripe_subscription_id: false,
paypal_subscription_id: false,
billing_provider: false,
},
})
return NextResponse.json({ profile: profile ?? null })
+13 -1
View File
@@ -1,7 +1,13 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import {
saveFile,
isAllowedUploadExt,
StorageNotConfiguredError,
contentMatchesExtension,
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
@@ -33,6 +39,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
}
// Reject files whose real content doesn't match the claimed extension.
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
if (!contentMatchesExtension(head, extOf(file.name))) {
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
}
// Enforce per-plan storage quota (accounts for everything already stored in
// the owner's portfolio namespace).
const storageError = await checkStorageLimit(ownerId, file.size)
+2
View File
@@ -1,5 +1,6 @@
"use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react"
export default function GlobalError({
@@ -10,6 +11,7 @@ export default function GlobalError({
reset: () => void
}) {
useEffect(() => {
Sentry.captureException(error)
console.error(error)
}, [error])
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { Toaster } from "@/components/ui/toaster"
import { UmamiAnalytics } from "@/components/analytics/umami"
import { CookieConsent } from "@/components/shared/cookie-consent"
import "./globals.css"
const geistSans = Geist({
@@ -70,6 +71,7 @@ export default function RootLayout({
<body suppressHydrationWarning className="min-h-full flex flex-col bg-[#09090b] text-white">
{children}
<Toaster />
<CookieConsent />
<UmamiAnalytics />
</body>
</html>
+14 -2
View File
@@ -12,11 +12,23 @@ export default function manifest(): MetadataRoute.Manifest {
theme_color: "#09090b",
icons: [
{
src: "/logo-mark.png",
src: "/icon-192.png",
type: "image/png",
sizes: "100x100",
sizes: "192x192",
purpose: "any",
},
{
src: "/icon-512.png",
type: "image/png",
sizes: "512x512",
purpose: "any",
},
{
src: "/icon-maskable-512.png",
type: "image/png",
sizes: "512x512",
purpose: "maskable",
},
],
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
return {
rules: {
userAgent: "*",
allow: "/",
// Private/app areas. Mirrors PROTECTED_PATHS in proxy.ts, plus the API
// surface and the token-gated tenant portal (both private but enforced
// outside the cookie proxy). `/tenant-portal/` keeps the trailing slash so
// it doesn't also block the indexable `/tenant-portal-info` marketing page.
disallow: [
"/dashboard",
"/admin",
"/api/",
"/settings",
"/onboarding",
"/team",
"/tenant-portal/",
"/calendar",
"/inspections",
"/vendors",
"/reports",
"/activity",
"/ai",
"/ai-dashboard",
"/predictions",
"/recommendations",
"/impact",
"/follow-ups",
"/properties",
"/tenants",
"/rent",
"/maintenance",
"/leases",
"/expenses",
],
},
sitemap: `${base}/sitemap.xml`,
host: base,
}
}
-21
View File
@@ -1,21 +0,0 @@
export async function GET() {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
const body = `User-agent: *
Allow: /
Disallow: /dashboard
Disallow: /properties
Disallow: /tenants
Disallow: /rent
Disallow: /maintenance
Disallow: /leases
Disallow: /expenses
Disallow: /settings
Disallow: /tenant-portal/
Disallow: /api/
Sitemap: ${appUrl}/sitemap.xml`
return new Response(body, {
headers: { "Content-Type": "text/plain" },
})
}
+33 -12
View File
@@ -1,17 +1,38 @@
import type { MetadataRoute } from "next"
import { LEGAL_PAGES } from "@/lib/legal"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
type ChangeFrequency = MetadataRoute.Sitemap[number]["changeFrequency"]
// Single source of truth for the public, indexable URL surface. Every entry
// must resolve to a real 200 page that is NOT noindex'd. Private/app routes are
// blocked in app/robots.ts, and the /login and /forgot-password auth pages are
// noindex, so all three are intentionally omitted here. /signup is kept as a
// conversion landing page.
const PAGES: { path: string; changeFrequency: ChangeFrequency; priority: number }[] = [
{ path: "/", changeFrequency: "weekly", priority: 1 },
{ path: "/tenant-portal-info", changeFrequency: "monthly", priority: 0.8 },
{ path: "/api-docs", changeFrequency: "monthly", priority: 0.8 },
{ path: "/signup", changeFrequency: "monthly", priority: 0.7 },
// Legal pages are derived from the shared LEGAL_PAGES constant (the same list
// the footer renders) so the sitemap can never drift from the real routes.
...LEGAL_PAGES.map((page) => ({
path: page.href,
changeFrequency: "yearly" as const,
priority: 0.3,
})),
]
export default function sitemap(): MetadataRoute.Sitemap {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
const lastModified = new Date("2026-07-01")
// Build-time timestamp, refreshed on every deploy. We don't track per-page
// modification dates, so a single honest "last built" date is used throughout.
const lastModified = new Date()
return [
{ url: base, lastModified, changeFrequency: "weekly", priority: 1 },
{ url: `${base}/tenant-portal-info`, lastModified, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/api-docs`, lastModified, changeFrequency: "monthly", priority: 0.6 },
{ url: `${base}/signup`, lastModified, changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/privacy`, lastModified, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/terms`, lastModified, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/cookie-policy`, lastModified, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/gdpr`, lastModified, changeFrequency: "yearly", priority: 0.3 },
]
return PAGES.map(({ path, changeFrequency, priority }) => ({
url: path === "/" ? base : `${base}${path}`,
lastModified,
changeFrequency,
priority,
}))
}
+119
View File
@@ -0,0 +1,119 @@
"use client"
import { useState, useTransition } from "react"
import { toast } from "sonner"
import { Sparkles, Check, AlertTriangle } from "lucide-react"
import { setAiProviderAction } from "@/app/actions/admin"
type Provider = "openai" | "anthropic"
const LABELS: Record<Provider, string> = { openai: "OpenAI", anthropic: "Anthropic (Claude)" }
export function AiProviderToggle({
selected,
effective,
openaiConfigured,
anthropicConfigured,
openaiModel,
anthropicModel,
}: {
selected: Provider
effective: Provider
openaiConfigured: boolean
anthropicConfigured: boolean
openaiModel: string
anthropicModel: string
}) {
const [current, setCurrent] = useState<Provider>(selected)
const [pending, startTransition] = useTransition()
const configured: Record<Provider, boolean> = { openai: openaiConfigured, anthropic: anthropicConfigured }
const models: Record<Provider, string> = { openai: openaiModel, anthropic: anthropicModel }
function choose(next: Provider) {
if (next === current || pending) return
const prev = current
setCurrent(next)
startTransition(async () => {
try {
await setAiProviderAction(next)
toast.success(`AI provider set to ${LABELS[next]}`)
} catch {
setCurrent(prev) // revert optimistic change
toast.error("Couldn't switch the AI provider. Try again.")
}
})
}
// When the selected provider has no key on the server, AI falls back to the
// other configured provider (see lib/ai/provider). Surface that clearly.
const fallbackActive = effective !== current
const noneConfigured = !openaiConfigured && !anthropicConfigured
const options: Provider[] = ["openai", "anthropic"]
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Sparkles className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">AI provider</h2>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-xs text-white/40">
Choose which LLM powers all AI features (assistant, recommendations, predictions, summaries,
receipts). Applies to everyone immediately.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{options.map((p) => {
const active = current === p
return (
<button
key={p}
type="button"
onClick={() => choose(p)}
disabled={pending}
aria-pressed={active}
className={`flex items-start justify-between gap-3 rounded-xl border px-4 py-3 text-left transition disabled:opacity-60 ${
active
? "border-rose-500/40 bg-rose-500/[0.08]"
: "border-white/10 bg-white/[0.02] hover:bg-white/[0.05]"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">{LABELS[p]}</span>
{active && <Check className="h-3.5 w-3.5 text-rose-400" />}
</div>
<p className="mt-0.5 font-mono text-[11px] text-white/40 truncate">{models[p]}</p>
<p className="mt-1 text-[11px]">
{configured[p] ? (
<span className="text-emerald-400">API key configured</span>
) : (
<span className="text-amber-400">No API key on server</span>
)}
</p>
</div>
</button>
)
})}
</div>
{noneConfigured ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
No AI provider key is set on the server AI features return a 503 until{" "}
<code className="font-mono">OPENAI_API_KEY</code> or <code className="font-mono">ANTHROPIC_API_KEY</code> is configured.
</p>
) : fallbackActive ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
{LABELS[current]} has no API key on this server, so AI is temporarily running on{" "}
<span className="font-semibold">{LABELS[effective]}</span>. Add the key to use {LABELS[current]}.
</p>
) : null}
</div>
</div>
)
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import { useEffect, useState, useTransition } from "react"
import { toast } from "sonner"
import {
PenLine,
Link2,
CheckCircle2,
AlertTriangle,
KeyRound,
ChevronDown,
ExternalLink,
Loader2,
} from "lucide-react"
import { connectDropboxSign, disconnectEsignAction } from "@/app/actions/esign"
type Adapter = { id: string; label: string; kind: "oauth" | "apikey"; available: boolean }
type Conn = { provider: string; accountName: string | null; status: string; lastError: string | null }
const ERR_MSG: Record<string, string> = {
connect_failed: "Connection failed — please try again.",
invalid_state: "The connection link expired or was invalid. Please retry.",
owner_only: "Only the account owner can manage integrations.",
not_configured: "E-signature isn't enabled on this server yet.",
unknown_provider: "Unknown provider.",
use_api_key: "That provider connects with an API key, not a redirect.",
}
const LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
function StatusPill({ status }: { status: string }) {
const error = status === "error"
return (
<span
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
error ? "border-red-500/20 bg-red-500/10 text-red-400" : "border-emerald-500/20 bg-emerald-500/10 text-emerald-400"
}`}
>
{error ? <AlertTriangle className="h-3 w-3" /> : <CheckCircle2 className="h-3 w-3" />}
{error ? "Error" : "Connected"}
</span>
)
}
export function EsignIntegrations({
adapters,
connections,
isOwner,
flash,
webhookUrl,
}: {
adapters: Adapter[]
connections: Conn[]
isOwner: boolean
flash: { connected?: string; error?: string }
webhookUrl: string
}) {
const connByProvider: Record<string, Conn> = Object.fromEntries(connections.map((c) => [c.provider, c]))
const [pending, start] = useTransition()
const [busy, setBusy] = useState<string | null>(null)
const [open, setOpen] = useState<string | null>(null) // which provider's instructions are expanded
const [apiKey, setApiKey] = useState("")
const [showKeyForm, setShowKeyForm] = useState(false)
useEffect(() => {
if (flash.connected) toast.success(`Connected to ${LABEL[flash.connected] ?? flash.connected}`)
if (flash.error) toast.error(ERR_MSG[flash.error] ?? "Something went wrong")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function connectDbx() {
const key = apiKey.trim()
if (!key) return
setBusy("dropbox_sign")
start(async () => {
try {
const r = await connectDropboxSign(key)
toast.success(`Connected ${r.accountName ?? "Dropbox Sign"}`)
setApiKey("")
setShowKeyForm(false)
} catch (e) {
toast.error((e as Error).message || "Couldn't connect")
} finally {
setBusy(null)
}
})
}
function remove(id: string) {
if (!confirm(`Disconnect ${LABEL[id] ?? id}? You won't be able to send leases through it until you reconnect.`)) return
setBusy(id)
start(async () => {
try {
await disconnectEsignAction(id)
toast.success("Disconnected")
} catch {
toast.error("Couldn't disconnect")
} finally {
setBusy(null)
}
})
}
if (!isOwner) {
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6 text-sm text-white/50">
Only the account owner can connect e-signature providers.
</div>
)
}
return (
<div className="space-y-3">
{/* Intro / how it works */}
<div className="rounded-2xl border border-indigo-500/15 bg-indigo-500/[0.04] p-5">
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">Send leases for e-signature</h3>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-white/50">
Connect <span className="text-white/80">your own</span> DocuSign or Dropbox Sign account so signed leases carry
your brand and audit trail and the signing costs stay on your provider plan, not ours. Once connected, a
<span className="text-white/80"> Send for signature </span> button appears on every lease that has a document
and a tenant email.
</p>
<ol className="mt-3 space-y-1.5 text-xs text-white/50">
<li>1. Connect your provider below (one-time).</li>
<li>2. Open a lease upload the lease PDF.</li>
<li>3. Click Send via DocuSign / Dropbox Sign. The tenant signs; the status updates here automatically and the signed copy is saved back to the lease.</li>
</ol>
</div>
{adapters.map((a) => {
const conn = connByProvider[a.id]
const isBusy = pending && busy === a.id
const instructionsOpen = open === a.id
return (
<div key={a.id} className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/[0.04] text-white/70">
<PenLine className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-white">{a.label}</p>
{conn ? (
<p className="text-xs text-white/40">{conn.accountName ?? "Connected"}</p>
) : (
<p className="text-xs text-white/40">
{a.kind === "oauth" ? "Connect with your DocuSign login" : "Connect with your API key"}
</p>
)}
</div>
</div>
{conn ? (
<StatusPill status={conn.status} />
) : !a.available ? (
<span className="shrink-0 rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-400">
Not available
</span>
) : null}
</div>
{conn?.status === "error" && conn.lastError && (
<p className="mt-3 rounded-lg border border-red-500/15 bg-red-500/[0.06] px-3 py-2 text-xs text-red-300/90">{conn.lastError}</p>
)}
{/* Actions */}
<div className="mt-4 flex flex-wrap items-center gap-2">
{conn ? (
<button
onClick={() => remove(a.id)}
disabled={isBusy}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Disconnect"}
</button>
) : !a.available ? (
<p className="text-xs text-white/30">
Ask your administrator to enable {a.label} (server credentials aren&apos;t configured).
</p>
) : a.kind === "oauth" ? (
<a
href={`/api/esign/${a.id}/connect`}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
<Link2 className="h-3.5 w-3.5" /> Connect {a.label}
</a>
) : showKeyForm ? (
<div className="flex w-full flex-col gap-2 sm:flex-row">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your Dropbox Sign API key"
className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-white placeholder-white/30 outline-none focus:border-indigo-500/50"
/>
<button
onClick={connectDbx}
disabled={isBusy || !apiKey.trim()}
className="flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />} Connect
</button>
</div>
) : (
<button
onClick={() => setShowKeyForm(true)}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
<KeyRound className="h-3.5 w-3.5" /> Connect {a.label}
</button>
)}
{a.available && (
<button
onClick={() => setOpen(instructionsOpen ? null : a.id)}
className="ml-auto flex items-center gap-1 text-[11px] text-white/40 transition hover:text-white/70"
>
How to connect <ChevronDown className={`h-3 w-3 transition ${instructionsOpen ? "rotate-180" : ""}`} />
</button>
)}
</div>
{/* Instructions */}
{instructionsOpen && (
<div className="mt-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4 text-xs leading-relaxed text-white/55">
{a.id === "docusign" ? (
<ol className="space-y-1.5">
<li>
1. You need an active{" "}
<a href="https://www.docusign.com/products/electronic-signature" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
DocuSign eSignature plan <ExternalLink className="h-3 w-3" />
</a>.
</li>
<li>2. Click <span className="text-white/80">Connect DocuSign</span> above.</li>
<li>3. Log in to <span className="text-white/80">your</span> DocuSign account and click <span className="text-white/80">Allow</span> to grant access.</li>
<li>4. You&apos;ll return here connected no webhook setup needed. Signed-status updates and the completed PDF flow back automatically.</li>
</ol>
) : (
<ol className="space-y-1.5">
<li>
1. In Dropbox Sign, open{" "}
<a href="https://app.hellosign.com/account/settings/api" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
Settings API <ExternalLink className="h-3 w-3" />
</a>{" "}
and copy your <span className="text-white/80">API key</span>.
</li>
<li>2. Paste it above and click <span className="text-white/80">Connect</span>.</li>
<li>
3. In the same API settings, set your <span className="text-white/80">account callback URL</span> to:
<code className="mt-1 block overflow-x-auto rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-emerald-300">{webhookUrl}</code>
This lets us receive signed-status updates.
</li>
</ol>
)}
</div>
)}
</div>
)
})}
<p className="px-1 text-[11px] text-white/30">
Your credentials are encrypted at rest and never leave the server. We only send the leases you explicitly submit.
</p>
</div>
)
}
+1
View File
@@ -16,6 +16,7 @@ const pageTitles: Record<string, string> = {
"/expenses": "Expenses",
"/settings/profile": "Settings",
"/settings/billing": "Billing",
"/settings/privacy": "Privacy & Data",
"/settings/demo": "Demo Data",
"/ai": "AI Assistant",
"/reports": "Reports",
+210
View File
@@ -0,0 +1,210 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Download, Loader2, ShieldCheck, Trash2, TriangleAlert } from "lucide-react"
import { requestAccountDeletion, cancelAccountDeletion } from "@/app/actions/gdpr"
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
function formatDate(value: string): string {
const d = new Date(value)
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })
}
function daysUntil(value: string): number {
return Math.max(0, Math.ceil((new Date(value).getTime() - Date.now()) / 86_400_000))
}
export function PrivacyManager({
accountEmail,
graceDays,
pendingDeletion,
}: {
accountEmail: string
graceDays: number
pendingDeletion: { scheduled_for: string; created_at: string } | null
}) {
const router = useRouter()
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmEmail, setConfirmEmail] = useState("")
const [reason, setReason] = useState("")
const [busy, setBusy] = useState(false)
async function handleRequestDeletion(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setBusy(true)
try {
await requestAccountDeletion({ confirmEmail, reason })
toast.success("Account deletion scheduled. Check your email for confirmation.")
setConfirmOpen(false)
setConfirmEmail("")
setReason("")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to schedule deletion")
} finally {
setBusy(false)
}
}
async function handleCancelDeletion() {
setBusy(true)
try {
await cancelAccountDeletion()
toast.success("Deletion cancelled — your account is safe.")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to cancel deletion")
} finally {
setBusy(false)
}
}
return (
<div className="space-y-6">
{/* ── Export ────────────────────────────────────────────────────────── */}
<div className="rounded-xl border border-white/10 bg-[#111118] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-indigo-500/10 p-2">
<ShieldCheck className="h-5 w-5 text-indigo-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Export your data</p>
<p className="mt-0.5 text-xs text-white/40">
Download a machine-readable JSON file with everything we store about you and your
portfolio profile, properties, tenants, payments, documents metadata, activity, and
consent history. Passwords and connected-service credentials are never included.
</p>
<a
href="/api/gdpr/export"
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
>
<Download className="h-3.5 w-3.5" />
Download my data
</a>
</div>
</div>
</div>
{/* ── Delete account ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-red-500/25 bg-red-500/[0.04] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-red-500/10 p-2">
<Trash2 className="h-5 w-5 text-red-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Delete your account</p>
{pendingDeletion ? (
<>
<div className="mt-3 rounded-lg border border-red-500/25 bg-red-500/10 px-4 py-3">
<p className="flex items-center gap-2 text-sm font-semibold text-red-300">
<TriangleAlert className="h-4 w-4 shrink-0" />
Deletion scheduled for {formatDate(pendingDeletion.scheduled_for)}
</p>
<p className="mt-1 text-xs text-red-200/70">
{daysUntil(pendingDeletion.scheduled_for)} days left. Your account stays fully
usable until then. After that date, all data and files are permanently erased.
</p>
</div>
<button
type="button"
onClick={handleCancelDeletion}
disabled={busy}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-white/10 disabled:opacity-50"
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Cancel deletion keep my account
</button>
</>
) : (
<>
<p className="mt-0.5 text-xs text-white/40">
Permanently deletes your account, all properties, tenants, payments, documents,
and uploaded files, and cancels any active subscription. There is a{" "}
{graceDays}-day grace period during which you can change your mind after that,
deletion is irreversible.
</p>
{!confirmOpen ? (
<button
type="button"
onClick={() => setConfirmOpen(true)}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-3.5 py-2 text-xs font-semibold text-red-300 transition hover:bg-red-500/20"
>
<Trash2 className="h-3.5 w-3.5" />
Delete my account
</button>
) : (
<form onSubmit={handleRequestDeletion} className="mt-4 space-y-3">
<div>
<label
htmlFor="confirm-email"
className="mb-1.5 block text-xs font-medium text-white/70"
>
Type your account email (<span className="text-white/40">{accountEmail}</span>)
to confirm
</label>
<input
id="confirm-email"
type="email"
required
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
placeholder={accountEmail}
className={inputClass}
autoComplete="off"
/>
</div>
<div>
<label
htmlFor="deletion-reason"
className="mb-1.5 block text-xs font-medium text-white/70"
>
Reason <span className="text-white/30">(optional helps us improve)</span>
</label>
<textarea
id="deletion-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={2}
maxLength={500}
className={inputClass}
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || confirmEmail.trim().toLowerCase() !== accountEmail.toLowerCase()}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-red-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Schedule permanent deletion
</button>
<button
type="button"
onClick={() => setConfirmOpen(false)}
className="rounded-lg px-3.5 py-2 text-xs font-medium text-white/40 transition hover:text-white/70"
>
Never mind
</button>
</div>
</form>
)}
</>
)}
</div>
</div>
</div>
</div>
)
}
+23 -1
View File
@@ -7,7 +7,7 @@ import {
LayoutDashboard, Building2, Users, CreditCard,
Wrench, FileText, Receipt, Settings, LogOut,
X, Menu, ChevronRight, Zap, Sparkles, Bot, BarChart3, Hammer, ClipboardList,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook, ShieldCheck,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Logo, LogoMark } from "@/components/shared/logo"
@@ -245,6 +245,28 @@ function NavContent({
{!collapsed && "Webhooks"}
</Link>
<Link
href="/settings/privacy"
onClick={onClose}
title={collapsed ? "Privacy & Data" : undefined}
className={cn(
"group relative flex items-center rounded-xl transition-all duration-200",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
pathname === "/settings/privacy"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/privacy" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<ShieldCheck className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/privacy" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Privacy & Data"}
</Link>
{(plan === "landlord" || plan === "lifetime") && (
<>
<Link
+1 -36
View File
@@ -9,7 +9,6 @@ export function CheckoutButton({
highlight,
interval = "month",
annualAvailable = false,
paypalEnabled = false,
}: {
plan: string
label: string
@@ -18,11 +17,8 @@ export function CheckoutButton({
// When true, show a monthly/annual choice. Only pass this for subscription
// plans and only when annual billing is actually configured server-side.
annualAvailable?: boolean
// When true, also offer "Pay with PayPal" using the same interval choice.
paypalEnabled?: boolean
}) {
const [loading, setLoading] = useState(false)
const [paypalLoading, setPaypalLoading] = useState(false)
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
const effectiveInterval = annualAvailable ? chosenInterval : interval
@@ -39,21 +35,6 @@ export function CheckoutButton({
else setLoading(false)
}
async function handlePaypal() {
setPaypalLoading(true)
const res = await fetch("/api/paypal/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan, interval: effectiveInterval }),
})
const data = await res.json()
if (data.url) window.location.href = data.url
else {
setPaypalLoading(false)
if (data.error) alert(data.error)
}
}
return (
<div className="space-y-2">
{annualAvailable && (
@@ -82,7 +63,7 @@ export function CheckoutButton({
)}
<button
onClick={handleClick}
disabled={loading || paypalLoading}
disabled={loading}
className={cn(
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
highlight
@@ -92,22 +73,6 @@ export function CheckoutButton({
>
{loading ? "Loading..." : label}
</button>
{paypalEnabled && (
<button
onClick={handlePaypal}
disabled={loading || paypalLoading}
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-[#ffc439] py-2 text-xs font-bold text-[#003087] transition hover:bg-[#f0b90b] disabled:opacity-50"
>
{paypalLoading ? (
"Loading..."
) : (
<>
Pay with <span className="font-extrabold italic">Pay<span className="text-[#009cde]">Pal</span></span>
</>
)}
</button>
)}
</div>
)
}
+24 -8
View File
@@ -1,8 +1,9 @@
"use client"
import { useTransition } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle, Download } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
@@ -12,11 +13,12 @@ type Req = {
status: string
signer_email: string
document_name: string | null
signed_document_url: string | null
sent_at: string | null
completed_at: string | null
last_error: string | null
}
type Prov = { id: string; label: string; configured: boolean }
type Prov = { id: string; label: string }
const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = {
sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock },
@@ -29,18 +31,17 @@ const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_s
export function EsignLease({
leaseId,
providers,
connected,
requests,
canSend,
disabledReason,
}: {
leaseId: string
providers: Prov[]
connected: Prov[]
requests: Req[]
canSend: boolean
disabledReason: string
}) {
const configured = providers.filter((p) => p.configured)
const [pending, start] = useTransition()
function send(provider: string) {
@@ -74,6 +75,16 @@ export function EsignLease({
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
</p>
{r.signed_document_url && (
<a
href={r.signed_document_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-[11px] text-indigo-400 transition hover:text-indigo-300"
>
<Download className="h-3 w-3" /> Signed document
</a>
)}
</div>
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}>
<s.Icon className="h-3 w-3" /> {s.label}
@@ -84,11 +95,16 @@ export function EsignLease({
</ul>
)}
{configured.length === 0 ? (
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p>
{connected.length === 0 ? (
<p className="text-xs text-white/30">
<Link href="/settings/integrations" className="text-indigo-400 hover:text-indigo-300">
Connect DocuSign or Dropbox Sign
</Link>{" "}
in Settings Integrations to send leases for signature.
</p>
) : canSend ? (
<div className="flex flex-wrap gap-2">
{configured.map((p) => (
{connected.map((p) => (
<button
key={p.id}
onClick={() => send(p.id)}
+99
View File
@@ -0,0 +1,99 @@
"use client"
import { useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { FileText, Upload, ExternalLink, Loader2 } from "lucide-react"
import { setLeaseDocument } from "@/app/actions/esign"
export function LeaseDocument({
leaseId,
documentUrl,
canWrite,
}: {
leaseId: string
documentUrl: string | null
canWrite: boolean
}) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (!/\.(pdf|docx?)$/i.test(file.name)) {
toast.error("Upload a PDF or Word document")
if (inputRef.current) inputRef.current.value = ""
return
}
setUploading(true)
try {
const fd = new FormData()
fd.append("file", file)
fd.append("scope", "documents")
const res = await fetch("/api/upload", { method: "POST", body: fd })
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j?.error || "Upload failed")
}
const { url } = (await res.json()) as { url: string }
await setLeaseDocument(leaseId, url)
toast.success(documentUrl ? "Lease document replaced" : "Lease document attached")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed")
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ""
}
}
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium text-white">
<FileText className="h-4 w-4 shrink-0 text-indigo-400" />
{documentUrl ? "Lease document" : "No lease document yet"}
</div>
{documentUrl && (
<a
href={documentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-indigo-400 transition hover:text-indigo-300"
>
View <ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
{!documentUrl && (
<p className="mt-1.5 text-xs text-white/40">Upload the lease PDF to enable sending it for e-signature.</p>
)}
{canWrite && (
<div className="mt-3">
<input
ref={inputRef}
type="file"
accept=".pdf,.doc,.docx"
onChange={onPick}
disabled={uploading}
className="hidden"
id={`lease-doc-${leaseId}`}
/>
<label
htmlFor={`lease-doc-${leaseId}`}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/[0.06] hover:text-white ${
uploading ? "pointer-events-none opacity-50" : ""
}`}
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{documentUrl ? "Replace document" : "Upload lease document"}
</label>
</div>
)}
</div>
)
}
-32
View File
@@ -1,32 +0,0 @@
"use client"
import { useState } from "react"
export function PaypalCancelButton() {
const [loading, setLoading] = useState(false)
async function handleClick() {
if (!confirm("Cancel your PayPal subscription? You'll keep access until the end of the current billing period.")) {
return
}
setLoading(true)
const res = await fetch("/api/paypal/cancel", { method: "POST" })
const data = await res.json().catch(() => ({}))
if (res.ok) {
window.location.href = "/settings/billing?canceled=true"
} else {
setLoading(false)
alert(data.error || "Could not cancel the subscription.")
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-50"
>
{loading ? "Canceling..." : "Cancel Subscription"}
</button>
)
}
+20 -5
View File
@@ -1,5 +1,19 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
// we advertise only the monthly / one-time base prices that actually exist.
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
const planOffers = planOrder.map((plan) => ({
"@type": "Offer",
name: getPlanLabel(plan),
price: String(PLAN_AMOUNTS[plan]),
priceCurrency: "USD",
}))
// Mirrors the visible FAQ content in components/marketing/faq.tsx.
// Keep these in sync with that source so the JSON-LD matches what users see.
const faqs = [
@@ -43,6 +57,11 @@ const organization: Record<string, unknown> = {
name: "Property Management Network",
url: base,
logo: `${base}/logo-mark.png`,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: "support@propertymanagement.network",
},
sameAs: [
"https://twitter.com/propertymgmtnet",
"https://github.com/propertymanagement-network",
@@ -64,11 +83,7 @@ const softwareApplication: Record<string, unknown> = {
operatingSystem: "Web",
description:
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
offers: planOffers,
}
const faqPage: Record<string, unknown> = {
+137
View File
@@ -0,0 +1,137 @@
"use client"
import { useEffect, useSyncExternalStore } from "react"
import Link from "next/link"
import { Cookie } from "lucide-react"
// Cookie/privacy consent banner.
//
// The platform only sets strictly-necessary cookies (auth session, CSRF) and
// uses cookieless Umami analytics — so this banner is disclosure plus an
// analytics opt-out, not a tracking gate. "Essential only" sets the
// `umami.disabled` localStorage flag, which the Umami script honors, so the
// choice takes effect without a reload for subsequent page views.
//
// The choice is stored locally for everyone; signed-in users also get a row in
// consent_log via /api/gdpr/consent (anonymous visitors are a 204 no-op).
const STORAGE_KEY = "pmn-cookie-consent"
const CONSENT_VERSION = 1
type StoredConsent = { v: number; analytics: boolean; ts: string }
// ── localStorage as an external store (SSR-safe, lint-clean) ────────────────
let listeners: Array<() => void> = []
function subscribe(listener: () => void) {
listeners.push(listener)
return () => {
listeners = listeners.filter((l) => l !== listener)
}
}
function notify() {
for (const l of listeners) l()
}
function readStored(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
// Storage unavailable (private mode) — treat as "answered" so the banner
// doesn't nag on every render; the choice just can't persist.
return "unavailable"
}
}
function hasValidConsent(raw: string | null): boolean {
if (raw === null) return false
if (raw === "unavailable") return true
try {
return (JSON.parse(raw) as StoredConsent).v === CONSENT_VERSION
} catch {
return false
}
}
function applyAnalyticsChoice(analytics: boolean) {
try {
if (analytics) localStorage.removeItem("umami.disabled")
else localStorage.setItem("umami.disabled", "1")
} catch {
// Storage unavailable — nothing to apply.
}
}
export function CookieConsent() {
// Server snapshot says "answered" so nothing renders during SSR/hydration.
const raw = useSyncExternalStore(subscribe, readStored, () => "unavailable")
const visible = !hasValidConsent(raw)
// Re-apply a returning visitor's analytics opt-out (external system only).
useEffect(() => {
if (raw && raw !== "unavailable") {
try {
applyAnalyticsChoice((JSON.parse(raw) as StoredConsent).analytics)
} catch {
// Corrupt value — banner is showing anyway.
}
}
}, [raw])
function choose(analytics: boolean) {
const stored: StoredConsent = { v: CONSENT_VERSION, analytics, ts: new Date().toISOString() }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
} catch {
// Private mode — still honor the choice for this page view.
}
applyAnalyticsChoice(analytics)
// Record the choice server-side for signed-in users (fire-and-forget).
fetch("/api/gdpr/consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ analytics }),
}).catch(() => {})
notify()
}
if (!visible) return null
return (
<div className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6" role="dialog" aria-label="Cookie consent">
<div className="mx-auto flex max-w-3xl flex-col gap-4 rounded-2xl border border-white/10 bg-[#111118]/95 p-5 shadow-2xl shadow-black/50 backdrop-blur sm:flex-row sm:items-center">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-indigo-500/10 p-2">
<Cookie className="h-5 w-5 text-indigo-400" />
</div>
<p className="text-xs leading-relaxed text-white/60">
We only use strictly-necessary cookies (sign-in and security) plus cookieless,
privacy-friendly analytics. Choose &ldquo;Essential only&rdquo; to opt out of analytics.
Details in our{" "}
<Link href="/cookie-policy" className="text-indigo-400 underline underline-offset-2 hover:text-indigo-300">
Cookie Policy
</Link>
.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 sm:flex-col md:flex-row">
<button
type="button"
onClick={() => choose(true)}
className="flex-1 whitespace-nowrap rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98] sm:w-full"
>
Accept all
</button>
<button
type="button"
onClick={() => choose(false)}
className="flex-1 whitespace-nowrap rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-white/70 transition hover:bg-white/10 sm:w-full"
>
Essential only
</button>
</div>
</div>
</div>
)
}
+2 -2
View File
@@ -1,12 +1,12 @@
// DigitalOcean Function invoked by scheduler triggers (see functions/project.yml).
// Calls the app's protected cron endpoint (`daily`, `late-fees`, `follow-ups`,
// or `webhooks`, chosen by the trigger body) with the CRON_SECRET bearer token.
// `webhooks`, or `gdpr`, chosen by the trigger body) with the CRON_SECRET bearer token.
// nodejs:18 has global fetch.
async function main(args) {
const base = (process.env.APP_BASE_URL || "").replace(/\/+$/, "")
const secret = process.env.CRON_SECRET
const requested = args && args.job
const allowed = ["daily", "late-fees", "follow-ups", "webhooks"]
const allowed = ["daily", "late-fees", "follow-ups", "webhooks", "gdpr"]
const job = allowed.includes(requested) ? requested : "daily"
if (!base || !secret) {
+8
View File
@@ -54,3 +54,11 @@ triggers:
withBody:
job: webhooks
function: cron/run
# GDPR: execute account deletions whose 30-day grace period elapsed — 07:00 UTC.
- name: gdpr
sourceType: scheduler
sourceDetails:
cron: '0 7 * * *'
withBody:
job: gdpr
function: cron/run
+21
View File
@@ -0,0 +1,21 @@
// Sentry initialization for the browser. Next.js loads this client-side
// instrumentation file automatically (Next 15.3+).
import * as Sentry from "@sentry/nextjs"
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
Sentry.init({
dsn,
// Inert until a DSN is configured.
enabled: !!dsn,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0,
// Session Replay: record 10% of all sessions, and 100% of sessions with an
// error. Text is masked and media blocked so tenant/landlord PII isn't captured.
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true })],
})
// Instrument client-side route transitions for tracing.
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart
+23 -4
View File
@@ -1,8 +1,19 @@
// Next.js instrumentation hook — runs once when the server process starts.
// Surfaces "silently disabled" integrations at boot so a misconfigured deploy is
// obvious in the logs instead of failing quietly at runtime.
export function register() {
// Only run in the Node.js server runtime (not edge/middleware) and only in prod.
// (1) Initializes Sentry for the active runtime, and (2) surfaces "silently
// disabled" integrations at boot so a misconfigured deploy is obvious in the
// logs instead of failing quietly at runtime.
import * as Sentry from "@sentry/nextjs"
export async function register() {
// Initialize Sentry for whichever server runtime is booting.
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config")
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config")
}
// --- Startup env warnings (Node.js server runtime, production only) ---
if (process.env.NEXT_RUNTIME !== "nodejs") return
if (process.env.NODE_ENV !== "production") return
@@ -33,4 +44,12 @@ export function register() {
if (!process.env.OPENAI_API_KEY) {
warn("OPENAI_API_KEY is NOT set — AI features will error when called.")
}
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) {
warn("SENTRY DSN is NOT set — error monitoring is disabled (no crash reports).")
}
}
// Capture errors thrown in nested React Server Components, route handlers, and
// server actions and report them to Sentry.
export const onRequestError = Sentry.captureRequestError
+28 -9
View File
@@ -1,22 +1,41 @@
import crypto from "crypto"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider
// and is tamper-proof, so the callback can't be forged/CSRF'd.
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider,
// a random nonce (bound to a cookie by the connect route for CSRF protection),
// and an issued-at timestamp so a leaked state can't be replayed indefinitely.
export function signState(data: { ownerId: string; provider: string }): string {
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
// Short-lived httpOnly cookie the connect route sets and the callback verifies
// against the state's nonce (binds the OAuth round-trip to the initiating browser).
export const OAUTH_NONCE_COOKIE = "acct_oauth_nonce"
// No insecure fallback: signing/verifying state without the real secret would
// let anyone forge a state for any owner, so we fail closed (mirrors lib/crypto.ts).
function secret(): string {
const s = process.env.BETTER_AUTH_SECRET
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
return s
}
export type OAuthState = { ownerId: string; provider: string; nonce: string }
export function signState(data: OAuthState): string {
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): { ownerId: string; provider: string } | null {
export function verifyState(state: string): OAuthState | null {
const [payload, sig] = state.split(".")
if (!payload || !sig) return null
const expect = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as OAuthState & { iat?: number }
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
} catch {
return null
}
+1
View File
@@ -12,6 +12,7 @@ export type AdminAction =
| "delete_user"
| "resend_verification"
| "maintenance_mode"
| "ai_provider"
/**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating
+15
View File
@@ -0,0 +1,15 @@
import Anthropic from "@anthropic-ai/sdk"
// Lazily construct the Anthropic client so `next build` does NOT require
// ANTHROPIC_API_KEY — it's only needed at runtime when the admin selects the
// Anthropic (Claude) provider for AI features. Mirrors lib/ai/client.ts.
let _anthropic: Anthropic | null = null
export function getAnthropic(): Anthropic {
if (!_anthropic) {
const key = process.env.ANTHROPIC_API_KEY
if (!key) throw new Error("ANTHROPIC_API_KEY is not set")
_anthropic = new Anthropic({ apiKey: key })
}
return _anthropic
}
+11
View File
@@ -1,5 +1,16 @@
import OpenAI from "openai"
// True when the server has an AI provider key (OpenAI OR Anthropic). AI routes
// check this up front and return a friendly 503 instead of throwing, matching
// how the other optional integrations (Stripe / SMTP / Turnstile) degrade when
// unconfigured. The active provider is chosen by an admin (see lib/ai/provider).
export function aiConfigured(): boolean {
return Boolean(process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY)
}
export const AI_UNCONFIGURED_ERROR =
"AI features aren't configured on this server (no OpenAI or Anthropic API key). Ask your administrator to enable them."
// Lazily construct the OpenAI client so `next build` does NOT require
// OPENAI_API_KEY — it's only needed at runtime. Call sites keep using
// `openai.xxx` unchanged; the Proxy builds the real client on first access.
+146
View File
@@ -0,0 +1,146 @@
import { eq } from "drizzle-orm"
import type OpenAI from "openai"
import { db } from "@/lib/db"
import { app_settings } from "@/lib/db/schema"
import { openai } from "@/lib/ai/client"
import { getAnthropic } from "@/lib/ai/anthropic"
// ============================================================================
// AI provider abstraction — one call site for every AI feature, backed by
// EITHER OpenAI or Anthropic (Claude). The active provider is chosen by an admin
// in Settings → System (persisted in app_settings), falling back to whichever
// provider actually has an API key configured on the server.
// ============================================================================
export type AiProvider = "openai" | "anthropic"
export type AiRole = "system" | "user" | "assistant"
export type AiMessage = { role: AiRole; content: string }
export const AI_PROVIDER_KEY = "ai_provider"
// Models are env-overridable. Both default to each provider's cheapest tier to
// keep token spend low: OpenAI gpt-4o-mini, Anthropic Claude Haiku 4.5 ($1/$5
// per 1M). Pin a stronger model via OPENAI_MODEL / ANTHROPIC_MODEL if desired.
export const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini"
export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5"
export function openaiConfigured(): boolean {
return Boolean(process.env.OPENAI_API_KEY)
}
export function anthropicConfigured(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY)
}
function isProvider(v: unknown): v is AiProvider {
return v === "openai" || v === "anthropic"
}
/** The admin-selected provider (defaults to openai). Fails safe to openai. */
export async function getAiProvider(): Promise<AiProvider> {
try {
const row = await db.query.app_settings.findFirst({
where: eq(app_settings.key, AI_PROVIDER_KEY),
})
const v = (row?.value as { provider?: string } | null)?.provider
return isProvider(v) ? v : "openai"
} catch {
return "openai"
}
}
/** Persist the admin's provider choice. Admin-gated by the calling action. */
export async function setAiProvider(provider: AiProvider): Promise<void> {
await db
.insert(app_settings)
.values({ key: AI_PROVIDER_KEY, value: { provider } })
.onConflictDoUpdate({
target: app_settings.key,
set: { value: { provider }, updated_at: new Date().toISOString() },
})
}
/**
* The provider actually used for a request: the selected one, unless it has no
* API key on the server and the other provider does — then we fall back so AI
* features keep working after a provider switch even if the key isn't set yet.
*/
function resolveEffective(selected: AiProvider): AiProvider {
if (selected === "anthropic" && !anthropicConfigured() && openaiConfigured()) return "openai"
if (selected === "openai" && !openaiConfigured() && anthropicConfigured()) return "anthropic"
return selected
}
/** Everything the admin UI needs to render the provider picker. */
export async function aiProviderStatus() {
const selected = await getAiProvider()
return {
selected,
effective: resolveEffective(selected),
openaiConfigured: openaiConfigured(),
anthropicConfigured: anthropicConfigured(),
openaiModel: OPENAI_MODEL,
anthropicModel: ANTHROPIC_MODEL,
}
}
/** Strip a ```json fenced code block, if the model wrapped its JSON in one. */
function stripFences(s: string): string {
return s
.replace(/^\s*```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim()
}
/**
* Provider-agnostic single-shot completion. Returns the model's text output.
*
* `json: true` asks for a JSON object (OpenAI uses response_format; both
* providers rely on the prompt saying "JSON only") and strips any code fences
* so the caller can `JSON.parse` the result directly.
*/
export async function aiComplete(opts: {
messages: AiMessage[]
maxTokens?: number
json?: boolean
}): Promise<string> {
const provider = resolveEffective(await getAiProvider())
const maxTokens = opts.maxTokens ?? 1024
let text: string
if (provider === "anthropic") {
// Anthropic takes a top-level `system`; the rest are user/assistant turns.
const system = opts.messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n\n")
const convo = opts.messages
.filter((m) => m.role !== "system")
.map((m) => ({ role: (m.role === "assistant" ? "assistant" : "user") as "assistant" | "user", content: m.content }))
if (convo.length === 0) convo.push({ role: "user", content: system || "Continue." })
const res = await getAnthropic().messages.create({
model: ANTHROPIC_MODEL,
max_tokens: maxTokens,
...(system ? { system } : {}),
messages: convo,
})
text = res.content.map((b) => (b.type === "text" ? b.text : "")).join("")
} else {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = opts.messages.map((m) =>
m.role === "system"
? { role: "system", content: m.content }
: m.role === "assistant"
? { role: "assistant", content: m.content }
: { role: "user", content: m.content }
)
const res = await openai.chat.completions.create({
model: OPENAI_MODEL,
max_tokens: maxTokens,
messages,
...(opts.json ? { response_format: { type: "json_object" as const } } : {}),
})
text = res.choices[0]?.message?.content ?? ""
}
return opts.json ? stripFences(text) : text
}
+35 -1
View File
@@ -3,8 +3,9 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { nextCookies } from "better-auth/next-js"
import { admin } from "better-auth/plugins"
import { db } from "@/lib/db"
import { user, session, account, verification, profiles } from "@/lib/db/schema"
import { user, session, account, verification, profiles, consent_log } from "@/lib/db/schema"
import { sendEmail, resetPasswordHtml, verifyEmailHtml } from "@/lib/email/send"
import { LEGAL } from "@/lib/legal"
// Bootstrap superadmins from env — no API path lets a user self-promote.
const ADMIN_USER_IDS = (process.env.ADMIN_USER_IDS ?? "")
@@ -80,6 +81,30 @@ export const auth = betterAuth({
} catch {
// Never block sign-up on profile creation.
}
// GDPR proof of acceptance: the signup form states that creating an
// account means agreeing to the Terms and Privacy Policy.
try {
await db.insert(consent_log).values([
{
user_id: u.id,
email: u.email,
kind: "terms" as const,
granted: true,
policy_version: LEGAL.lastUpdated,
source: "signup",
},
{
user_id: u.id,
email: u.email,
kind: "privacy" as const,
granted: true,
policy_version: LEGAL.lastUpdated,
source: "signup",
},
])
} catch {
// Never block sign-up on consent logging.
}
},
},
},
@@ -91,3 +116,12 @@ export const auth = betterAuth({
],
})
/**
* Whether Google OAuth is configured. The auth pages hide the "Continue with
* Google" button unless BOTH credentials are present, so users never see a
* social option that can't complete. Mirrors socialProviders.google above.
*/
export function isGoogleConfigured(): boolean {
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET)
}
+1
View File
@@ -254,6 +254,7 @@ export function getEnvHealth() {
"STRIPE_WEBHOOK_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"SMTP_HOST",
"SMTP_USER",
"GOOGLE_CLIENT_ID",
@@ -0,0 +1,17 @@
CREATE TABLE "esign_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"provider" text NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text,
"expires_at" timestamp with time zone,
"account_id" text,
"base_uri" text,
"account_name" text,
"status" text DEFAULT 'active' NOT NULL,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "esign_connections" ADD CONSTRAINT "esign_connections_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE "account_deletion_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"email" text,
"status" text DEFAULT 'pending' NOT NULL,
"reason" text,
"scheduled_for" timestamp with time zone NOT NULL,
"cancelled_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "consent_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text,
"email" text,
"kind" text NOT NULL,
"granted" boolean NOT NULL,
"policy_version" text,
"source" text,
"ip_address" text,
"user_agent" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "account_deletion_requests_pending_user_idx" ON "account_deletion_requests" USING btree ("user_id") WHERE status = 'pending';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -71,6 +71,20 @@
"when": 1782994066547,
"tag": "0009_amusing_blackheart",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1783017593260,
"tag": "0010_esign_connections",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1783071567630,
"tag": "0011_gdpr",
"breakpoints": true
}
]
}
+79
View File
@@ -11,6 +11,7 @@ import {
date,
jsonb,
doublePrecision,
uniqueIndex,
} from "drizzle-orm/pg-core"
// ============================================================
@@ -631,6 +632,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)
// ============================================================
@@ -689,6 +716,58 @@ export const webhook_deliveries = pgTable("webhook_deliveries", {
updated_at: updatedAt(),
})
// ============================================================
// ACCOUNT DELETION REQUESTS (GDPR right to erasure)
// ============================================================
// A user's self-service "delete my account" request. Deletion is deferred by a
// grace period (LEGAL.dataDeletionDays) during which the user can cancel; the
// gdpr cron then hard-deletes the account, its data, and its stored files.
// user_id is intentionally NOT a cascading FK — the completed request must
// survive the user's deletion as evidence the DSAR was honored. `email` is
// kept only while the request is pending (to notify) and nulled on completion.
export const account_deletion_requests = pgTable(
"account_deletion_requests",
{
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id").notNull(),
email: text("email"),
status: text("status").$type<"pending" | "cancelled" | "completed">().notNull().default("pending"),
reason: text("reason"),
scheduled_for: tstz("scheduled_for").notNull(),
cancelled_at: tstz("cancelled_at"),
completed_at: tstz("completed_at"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
created_at: createdAt(),
updated_at: updatedAt(),
},
(t) => [
// At most ONE open request per user — the request/cancel flow relies on this.
uniqueIndex("account_deletion_requests_pending_user_idx")
.on(t.user_id)
.where(sql`status = 'pending'`),
]
)
// ============================================================
// CONSENT LOG (GDPR proof of consent / acceptance)
// ============================================================
// Records when a person accepted the Terms/Privacy Policy (at signup) or made a
// cookie/marketing consent choice. user_id has no FK so the record survives
// account deletion as compliance evidence; identifying fields (email, ip) are
// anonymized by the deletion flow.
export const consent_log = pgTable("consent_log", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id"),
email: text("email"),
kind: text("kind").$type<"terms" | "privacy" | "cookies" | "marketing">().notNull(),
granted: boolean("granted").notNull(),
policy_version: text("policy_version"),
source: text("source"),
ip_address: text("ip_address"),
user_agent: text("user_agent"),
created_at: createdAt(),
})
// ============================================================
// RELATIONS (for Drizzle relational queries)
// ============================================================
+45
View File
@@ -221,6 +221,51 @@ export function teamInviteHtml({
})
}
export function accountDeletionRequestedHtml({
name,
scheduledDate,
graceDays,
}: {
name: string
scheduledDate: string
graceDays: number
}) {
return emailShell({
preheader: `Your account is scheduled for permanent deletion on ${scheduledDate}.`,
eyebrow: "Account deletion",
accent: BRAND.red,
title: "Your account deletion is scheduled",
intro: `Hi ${escapeHtml(name)}, we received your request to delete your account and all associated data.`,
body:
detailTable(
[
{ label: "Deletion date", value: scheduledDate, accent: true },
{ label: "Grace period", value: `${graceDays} days` },
],
BRAND.red
) +
paragraph(
"Until then your account stays fully usable, and you can cancel the deletion at any time from Settings → Privacy & Data. After the deletion date, ALL your properties, tenants, payments, documents, and uploaded files are permanently erased — this cannot be undone."
),
footerNote:
"If you did not request this, sign in and cancel the deletion immediately, then change your password.",
})
}
export function accountDeletionCompletedHtml() {
return emailShell({
preheader: "Your account and personal data have been permanently deleted.",
eyebrow: "Account deletion",
title: "Your account has been deleted",
intro:
"As requested, your account and the personal data associated with it have been permanently deleted from our systems.",
body: paragraph(
"Financial records we are legally required to retain (for example, invoices held by our payment processor) are kept only for as long as the law requires. Everything else — your properties, tenants, documents, and uploaded files — is gone."
),
footerNote: "Thanks for having used Property Management Network. You're welcome back anytime.",
})
}
export function followUpHtml(message: string) {
return emailShell({
preheader: message.slice(0, 140),
+104
View File
@@ -0,0 +1,104 @@
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { esign_connections } from "@/lib/db/schema"
import { encrypt, decrypt } from "@/lib/crypto"
import { getAdapter } from "./registry"
import type { ESignCredentials, ESignProvider, ESignTokens } from "./types"
// Per-owner e-sign connection storage + credential resolution. Mirrors
// lib/accounting/index.ts: tokens are AES-256-GCM encrypted at rest, decrypted
// on demand, and DocuSign access tokens are transparently refreshed near expiry.
/** Upsert an encrypted connection for (owner, provider). */
export async function saveEsignConnection(ownerId: string, provider: ESignProvider, tokens: ESignTokens) {
const values = {
user_id: ownerId,
provider,
access_token: encrypt(tokens.accessToken),
refresh_token: tokens.refreshToken ? encrypt(tokens.refreshToken) : null,
expires_at: tokens.expiresAt,
account_id: tokens.accountId,
base_uri: tokens.baseUri,
account_name: tokens.accountName,
status: "active" as const,
last_error: null,
}
const existing = await db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
columns: { id: true },
})
if (existing) {
await db
.update(esign_connections)
.set({ ...values, updated_at: new Date().toISOString() })
.where(eq(esign_connections.id, existing.id))
} else {
await db.insert(esign_connections).values(values)
}
}
export async function getEsignConnection(ownerId: string, provider: ESignProvider) {
return db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
})
}
/** Owner-facing list — never leaks tokens. */
export async function listEsignConnections(ownerId: string) {
const rows = await db.query.esign_connections.findMany({ where: eq(esign_connections.user_id, ownerId) })
return rows.map((r) => ({
provider: r.provider,
accountName: r.account_name,
status: r.status,
lastError: r.last_error,
}))
}
export async function disconnectEsign(ownerId: string, provider: ESignProvider) {
await db
.delete(esign_connections)
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}
/**
* Resolve ready-to-use credentials for a connected account, refreshing the
* DocuSign access token first if it's near expiry. Returns null when the owner
* hasn't connected this provider.
*/
export async function resolveEsignCreds(ownerId: string, provider: ESignProvider): Promise<ESignCredentials | null> {
const conn = await getEsignConnection(ownerId, provider)
if (!conn || conn.status === "revoked") return null
let accessToken = decrypt(conn.access_token)
const refreshToken = conn.refresh_token ? decrypt(conn.refresh_token) : null
let accountId = conn.account_id
let baseUri = conn.base_uri
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 5 * 60_000
if (nearExpiry && refreshToken) {
const adapter = getAdapter(provider)
if (adapter) {
const next = await adapter.refresh(refreshToken)
// Account id / base uri are stable across refresh — keep the stored ones.
await saveEsignConnection(ownerId, provider, {
...next,
accountId: conn.account_id,
baseUri: conn.base_uri,
accountName: conn.account_name,
})
accessToken = next.accessToken
accountId = conn.account_id
baseUri = conn.base_uri
}
}
return { provider, accessToken, refreshToken, accountId, baseUri }
}
/** Flag a connection as errored (e.g. after a failed send/refresh). */
export async function markEsignError(ownerId: string, provider: ESignProvider, message: string) {
await db
.update(esign_connections)
.set({ status: "error", last_error: message.slice(0, 500), updated_at: new Date().toISOString() })
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}
+135 -27
View File
@@ -1,44 +1,139 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
import type { ESignAdapter, ESignCredentials, ESignTokens, SendParams } from "./types"
import { esignRedirectUri } from "./types"
// DocuSign eSignature REST API. Uses a pre-obtained access token (via JWT grant
// or OAuth) — set DOCUSIGN_ACCESS_TOKEN / DOCUSIGN_ACCOUNT_ID / DOCUSIGN_BASE_URI.
// Docs: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
const ACCESS_TOKEN = process.env.DOCUSIGN_ACCESS_TOKEN ?? ""
const ACCOUNT_ID = process.env.DOCUSIGN_ACCOUNT_ID ?? ""
const BASE_URI = (process.env.DOCUSIGN_BASE_URI ?? "https://demo.docusign.net").replace(/\/+$/, "")
// DocuSign eSignature via per-landlord OAuth (Authorization Code Grant).
// The OPERATOR registers one DocuSign app and sets these; each LANDLORD then
// connects their own DocuSign account through it.
// DOCUSIGN_CLIENT_ID / DOCUSIGN_CLIENT_SECRET — the app's integration key + secret
// DOCUSIGN_OAUTH_BASE — "account-d.docusign.com" (demo) or "account.docusign.com" (prod)
const CLIENT_ID = process.env.DOCUSIGN_CLIENT_ID ?? ""
const CLIENT_SECRET = process.env.DOCUSIGN_CLIENT_SECRET ?? ""
const OAUTH_BASE = (process.env.DOCUSIGN_OAUTH_BASE ?? "account-d.docusign.com").replace(/^https?:\/\//, "").replace(/\/+$/, "")
function basicAuth() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
}
function extOf(name: string): string {
const e = name.split(".").pop()?.toLowerCase()
return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf"
}
/** Map a DocuSign envelope/event status string to one of our terminal statuses. */
function mapStatus(raw: string): "signed" | "declined" | "voided" | null {
const s = raw.toLowerCase()
if (s.includes("completed") || s.includes("signed")) return "signed"
if (s.includes("declined")) return "declined"
if (s.includes("voided")) return "voided"
return null
}
async function tokenRequest(form: Record<string, string>): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
const res = await fetch(`https://${OAUTH_BASE}/oauth/token`, {
method: "POST",
headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams(form),
})
if (!res.ok) throw new Error(`DocuSign token error ${res.status}: ${(await res.text()).slice(0, 300)}`)
return res.json()
}
async function userInfo(accessToken: string): Promise<{ accountId: string | null; baseUri: string | null; accountName: string | null }> {
const res = await fetch(`https://${OAUTH_BASE}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
})
if (!res.ok) throw new Error(`DocuSign userinfo ${res.status}`)
const j = (await res.json()) as { accounts?: { account_id: string; base_uri: string; account_name: string; is_default: boolean }[] }
const acct = j.accounts?.find((a) => a.is_default) ?? j.accounts?.[0]
return { accountId: acct?.account_id ?? null, baseUri: acct?.base_uri ?? null, accountName: acct?.account_name ?? null }
}
/** REST API base for the envelopes API, e.g. https://na3.docusign.net/restapi/v2.1/accounts/<id> */
function apiBase(creds: ESignCredentials): string {
return `${(creds.baseUri ?? "").replace(/\/+$/, "")}/restapi/v2.1/accounts/${creds.accountId}`
}
export const docusign: ESignAdapter = {
id: "docusign",
label: "DocuSign",
configured: () => Boolean(ACCESS_TOKEN && ACCOUNT_ID),
kind: "oauth",
available: () => Boolean(CLIENT_ID && CLIENT_SECRET),
async send({ document, documentName, signerEmail, signerName, subject }: SendParams) {
getAuthUrl(state) {
const p = new URLSearchParams({
response_type: "code",
// `extended` is required to receive a refresh token.
scope: "signature extended",
client_id: CLIENT_ID,
redirect_uri: esignRedirectUri("docusign"),
state,
})
return `https://${OAUTH_BASE}/oauth/auth?${p.toString()}`
},
async exchangeCode(code): Promise<ESignTokens> {
const t = await tokenRequest({ grant_type: "authorization_code", code })
const info = await userInfo(t.access_token)
return {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
accountId: info.accountId,
baseUri: info.baseUri,
accountName: info.accountName,
}
},
async refresh(refreshToken): Promise<ESignTokens> {
const t = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken })
// Account id / base uri are stable across refreshes; the resolver re-attaches them.
return {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(),
accountId: null,
baseUri: null,
accountName: null,
}
},
async connectApiKey(): Promise<ESignTokens> {
throw new Error("DocuSign connects via OAuth, not an API key")
},
async send(creds, p: SendParams) {
const envelope = {
emailSubject: subject,
emailSubject: p.subject,
status: "sent",
documents: [{ documentBase64: document.toString("base64"), name: documentName, fileExtension: extOf(documentName), documentId: "1" }],
documents: [{ documentBase64: p.document.toString("base64"), name: p.documentName, fileExtension: extOf(p.documentName), documentId: "1" }],
recipients: {
signers: [
{
email: signerEmail,
name: signerName,
email: p.signerEmail,
name: p.signerName,
recipientId: "1",
routingOrder: "1",
// Default sign placement (bottom of page 1). Use a template/anchor
// string for precise field placement in production.
tabs: { signHereTabs: [{ documentId: "1", pageNumber: "1", xPosition: "100", yPosition: "650" }] },
},
],
},
// Envelope-level Connect: DocuSign pings our webhook on completion so we
// pull the authoritative status. No account-level Connect config needed.
eventNotification: {
url: p.webhookUrl,
loggingEnabled: "true",
requireAcknowledgment: "true",
envelopeEvents: [
{ envelopeEventStatusCode: "completed" },
{ envelopeEventStatusCode: "declined" },
{ envelopeEventStatusCode: "voided" },
],
eventData: { version: "restv2.1" },
},
}
const res = await fetch(`${BASE_URI}/restapi/v2.1/accounts/${ACCOUNT_ID}/envelopes`, {
const res = await fetch(`${apiBase(creds)}/envelopes`, {
method: "POST",
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "Content-Type": "application/json" },
headers: { Authorization: `Bearer ${creds.accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify(envelope),
})
if (!res.ok) throw new Error(`DocuSign ${res.status}: ${(await res.text()).slice(0, 300)}`)
@@ -47,19 +142,32 @@ export const docusign: ESignAdapter = {
return { externalId: j.envelopeId }
},
parseWebhook(body): WebhookResult | null {
// DocuSign Connect (JSON format) payload.
peekExternalId(body): string | null {
try {
const j = JSON.parse(body) as { event?: string; data?: { envelopeId?: string; envelopeSummary?: { status?: string } } }
const externalId = j.data?.envelopeId
const status = (j.data?.envelopeSummary?.status ?? j.event ?? "").toLowerCase()
if (!externalId) return null
if (status.includes("completed") || status.includes("signed")) return { externalId, status: "signed" }
if (status.includes("declined")) return { externalId, status: "declined" }
if (status.includes("voided")) return { externalId, status: "voided" }
return null
const j = JSON.parse(body) as { data?: { envelopeId?: string } }
return j.data?.envelopeId ?? null
} catch {
return null
}
},
// We never trust the webhook body's status. Instead we fetch the envelope from
// DocuSign with the owner's own OAuth token — a forged webhook can at most make
// us re-read the real status, never fabricate a "signed".
async verifyAndGetStatus(creds, externalId) {
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}`, {
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/json" },
})
if (!res.ok) return null
const j = (await res.json()) as { status?: string }
return j.status ? mapStatus(j.status) : null
},
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}/documents/combined`, {
headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/pdf" },
})
if (!res.ok) return null
return Buffer.from(await res.arrayBuffer())
},
}
+91 -30
View File
@@ -1,30 +1,83 @@
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
import crypto from "crypto"
import type { ESignAdapter, ESignTokens, SendParams } from "./types"
// Dropbox Sign (formerly HelloSign). API-key auth. Docs:
// https://developers.hellosign.com/api/reference/operation/signatureRequestSend/
const API_KEY = process.env.DROPBOX_SIGN_API_KEY ?? ""
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
// Dropbox Sign (formerly HelloSign). Per-landlord API-key auth — each landlord
// pastes their own API key (no platform app credentials needed). Docs:
// https://developers.hellosign.com/api/reference/
const BASE = "https://api.hellosign.com/v3"
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
function auth() {
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64")
function authFor(apiKey: string) {
return "Basic " + Buffer.from(`${apiKey}:`).toString("base64")
}
/** Verify the `event_hash` (hex HMAC-SHA256 of event_time+event_type, key = API key). */
function verifyEventHash(apiKey: string, ev?: { event_type?: string; event_time?: string; event_hash?: string }): boolean {
if (!apiKey || !ev?.event_type || !ev?.event_time || !ev?.event_hash) return false
const expected = crypto.createHmac("sha256", apiKey).update(ev.event_time + ev.event_type).digest("hex")
const a = Buffer.from(ev.event_hash)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
type DbxEvent = {
event?: { event_type?: string; event_time?: string; event_hash?: string }
signature_request?: { signature_request_id?: string }
}
function parseBody(body: string): DbxEvent | null {
try {
// Dropbox Sign posts multipart form-data with a `json` field (or raw JSON).
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
return JSON.parse(m ? m[1] : body)
} catch {
return null
}
}
export const dropboxSign: ESignAdapter = {
id: "dropbox_sign",
label: "Dropbox Sign",
configured: () => Boolean(API_KEY),
kind: "apikey",
available: () => true, // landlord brings their own key; no operator setup required
async send({ document, documentName, signerEmail, signerName, subject, message }: SendParams) {
getAuthUrl(): string {
throw new Error("Dropbox Sign connects with an API key, not OAuth")
},
async exchangeCode(): Promise<ESignTokens> {
throw new Error("Dropbox Sign connects with an API key, not OAuth")
},
async refresh(): Promise<ESignTokens> {
throw new Error("Dropbox Sign API keys don't expire")
},
async connectApiKey(apiKey): Promise<ESignTokens> {
const key = apiKey.trim()
if (!key) throw new Error("Enter your Dropbox Sign API key")
const res = await fetch(`${BASE}/account`, { headers: { Authorization: authFor(key) } })
if (res.status === 401 || res.status === 403) throw new Error("That API key was rejected by Dropbox Sign")
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: could not validate the API key`)
const j = (await res.json()) as { account?: { email_address?: string } }
return {
accessToken: key,
refreshToken: null,
expiresAt: null,
accountId: null,
baseUri: null,
accountName: j.account?.email_address ?? "Dropbox Sign account",
}
},
async send(creds, p: SendParams) {
const fd = new FormData()
fd.append("subject", subject)
fd.append("message", message)
fd.append("subject", p.subject)
fd.append("message", p.message)
fd.append("test_mode", TEST_MODE)
fd.append("signers[0][email_address]", signerEmail)
fd.append("signers[0][name]", signerName)
fd.append("file[0]", new Blob([new Uint8Array(document)], { type: "application/pdf" }), documentName)
fd.append("signers[0][email_address]", p.signerEmail)
fd.append("signers[0][name]", p.signerName)
fd.append("file[0]", new Blob([new Uint8Array(p.document)], { type: "application/pdf" }), p.documentName)
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: auth() }, body: fd })
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: authFor(creds.accessToken) }, body: fd })
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: ${(await res.text()).slice(0, 300)}`)
const j = (await res.json()) as { signature_request?: { signature_request_id?: string } }
const id = j.signature_request?.signature_request_id
@@ -32,22 +85,30 @@ export const dropboxSign: ESignAdapter = {
return { externalId: id }
},
parseWebhook(body): WebhookResult | null {
// Dropbox Sign posts multipart form-data with a `json` field.
let event: { event?: { event_type?: string }; signature_request?: { signature_request_id?: string } }
try {
// Extract the JSON payload whether sent raw or as a form field.
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
event = JSON.parse(m ? m[1] : body)
} catch {
peekExternalId(body): string | null {
return parseBody(body)?.signature_request?.signature_request_id ?? null
},
async verifyAndGetStatus(creds, _externalId, body): Promise<"signed" | "declined" | "voided" | null> {
const ev = parseBody(body)
if (!ev || !verifyEventHash(creds.accessToken, ev.event)) return null
switch (ev.event?.event_type) {
case "signature_request_all_signed":
return "signed"
case "signature_request_declined":
return "declined"
case "signature_request_canceled":
return "voided"
default:
return null
}
const type = event.event?.event_type
const externalId = event.signature_request?.signature_request_id
if (!externalId || !type) return null
if (type === "signature_request_all_signed") return { externalId, status: "signed" }
if (type === "signature_request_declined") return { externalId, status: "declined" }
if (type === "signature_request_canceled") return { externalId, status: "voided" }
return null
},
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
const res = await fetch(`${BASE}/signature_request/files/${externalId}?file_type=pdf`, {
headers: { Authorization: authFor(creds.accessToken) },
})
if (!res.ok) return null
return Buffer.from(await res.arrayBuffer())
},
}
+73 -30
View File
@@ -1,42 +1,48 @@
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases, signature_requests } from "@/lib/db/schema"
import { readFile } from "@/lib/storage"
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
import { readFile, saveBuffer } from "@/lib/storage"
import { getAdapter } from "./registry"
import { resolveEsignCreds, markEsignError } from "./credentials"
import type { ESignProvider } from "./types"
export type { ESignProvider } from "./types"
export { getAdapter, listEsignAdapters } from "./registry"
export {
resolveEsignCreds,
listEsignConnections,
getEsignConnection,
saveEsignConnection,
disconnectEsign,
} from "./credentials"
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
const FILES_PREFIX = "/api/files/"
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
export function listAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({ id, label: ADAPTERS[id].label, configured: ADAPTERS[id].configured() }))
}
export function anyEsignConfigured(): boolean {
return listAdapters().some((a) => a.configured)
/** The webhook URL a provider should call back — used for DocuSign envelope-level Connect. */
function webhookUrl(provider: ESignProvider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/esign/${provider}/webhook`
}
/** Read the lease's stored document. Only /api/files keys are allowed (no SSRF). */
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
const prefix = "/api/files/"
if (documentUrl.startsWith(prefix)) {
const key = documentUrl.slice(prefix.length)
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.
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
// Adapter registry — dependency-free (no db) so it can be imported anywhere,
// including the credential resolver, without creating import cycles.
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
/** Providers the platform can offer, with their connect style + availability. */
export function listEsignAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({
id,
label: ADAPTERS[id].label,
kind: ADAPTERS[id].kind,
available: ADAPTERS[id].available(),
}))
}
+39
View File
@@ -0,0 +1,39 @@
import crypto from "crypto"
// Signed OAuth `state` for the e-sign connect flow — carries the initiating
// owner + provider + a random nonce (bound to a cookie by the connect route),
// plus an issued-at so a leaked state can't be replayed. Mirrors the hardened
// accounting OAuth state; fails closed if BETTER_AUTH_SECRET is missing.
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
export const ESIGN_NONCE_COOKIE = "esign_oauth_nonce"
function secret(): string {
const s = process.env.BETTER_AUTH_SECRET
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
return s
}
export type EsignOAuthState = { ownerId: string; provider: string; nonce: string }
export function signState(data: EsignOAuthState): string {
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): EsignOAuthState | null {
const [payload, sig] = state.split(".")
if (!payload || !sig) return null
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
try {
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as EsignOAuthState & { iat?: number }
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
} catch {
return null
}
}
+67 -11
View File
@@ -1,5 +1,27 @@
export type ESignProvider = "docusign" | "dropbox_sign"
export type ESignStatus = "signed" | "declined" | "voided"
/** Result of connecting an account (OAuth exchange or API-key validation). */
export interface ESignTokens {
accessToken: string
refreshToken: string | null
/** ISO expiry of the access token, or null (API keys don't expire). */
expiresAt: string | null
accountId: string | null
baseUri: string | null
accountName: string | null
}
/** Decrypted, ready-to-use credentials for a single connected account. */
export interface ESignCredentials {
provider: ESignProvider
accessToken: string
refreshToken?: string | null
accountId?: string | null
baseUri?: string | null
}
export interface SendParams {
document: Buffer
documentName: string
@@ -7,20 +29,54 @@ export interface SendParams {
signerName: string
subject: string
message: string
}
export interface WebhookResult {
externalId: string
status: "signed" | "declined" | "voided"
/** Our callback the provider should ping on status changes (DocuSign envelope-level Connect). */
webhookUrl: string
}
export interface ESignAdapter {
id: ESignProvider
label: string
/** True when this provider's credentials are configured in env. */
configured(): boolean
/** Send a document for signature; returns the provider's request/envelope id. */
send(p: SendParams): Promise<{ externalId: string }>
/** Parse an inbound webhook body into a status update (or null to ignore). */
parseWebhook(body: string, headers: Headers): WebhookResult | null
/** "oauth" → connect via redirect; "apikey" → connect by pasting a key. */
kind: "oauth" | "apikey"
/**
* True when the platform can offer this provider. OAuth providers need the
* operator's app credentials (client id/secret); API-key providers are always
* available because the landlord brings their own key.
*/
available(): boolean
// ── OAuth providers (DocuSign) ────────────────────────────────────────────
getAuthUrl(state: string): string
exchangeCode(code: string): Promise<ESignTokens>
refresh(refreshToken: string): Promise<ESignTokens>
// ── API-key providers (Dropbox Sign) ──────────────────────────────────────
/** Validate a pasted API key and return a token set to store. */
connectApiKey(apiKey: string): Promise<ESignTokens>
// ── Common ────────────────────────────────────────────────────────────────
/** Send a document for signature; returns the provider's envelope/request id. */
send(creds: ESignCredentials, params: SendParams): Promise<{ externalId: string }>
/** Extract the external id from an inbound (still UNVERIFIED) webhook body, for owner lookup. */
peekExternalId(body: string): string | null
/**
* Authenticate an inbound webhook and return the authoritative status.
* DocuSign pull-verifies by fetching the envelope with the owner's token;
* Dropbox Sign HMAC-verifies the body with the account API key. Returns null
* if the event isn't authentic or isn't a terminal status we track.
*/
verifyAndGetStatus(
creds: ESignCredentials,
externalId: string,
body: string,
headers: Headers
): Promise<ESignStatus | null>
/** Download the completed/executed document, or null if unavailable. */
getSignedDocument(creds: ESignCredentials, externalId: string): Promise<Buffer | null>
}
/** The OAuth callback URL for a provider (must match what's registered in the provider app). */
export function esignRedirectUri(provider: ESignProvider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/esign/${provider}/callback`
}
+189
View File
@@ -0,0 +1,189 @@
import { and, eq, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import {
user as userTable,
profiles,
verification,
consent_log,
admin_audit_log,
account_deletion_requests,
} from "@/lib/db/schema"
import { deleteUserStorage } from "@/lib/storage"
import { stripe } from "@/lib/stripe/client"
import { sendEmail, accountDeletionCompletedHtml } from "@/lib/email/send"
// ============================================================================
// GDPR account deletion (Article 17 — right to erasure).
//
// The DB schema does most of the cascading for us: every data table references
// profiles(id) ON DELETE CASCADE, and profiles references user(id) ON DELETE
// CASCADE — so deleting the auth user row erases the entire portfolio,
// sessions, and linked accounts in one statement. What the cascade CANNOT
// reach lives here: uploaded files in object storage, the Stripe
// subscription/customer, email-keyed verification rows, and PII embedded in
// the FK-less compliance tables (consent_log).
// ============================================================================
export type DeletionOutcome = {
userId: string
filesDeleted: number
stripeSubscription: "cancelled" | "none" | "error"
stripeCustomer: "deleted" | "none" | "error"
/** PayPal has no server-side cancel integration — surfaced so ops can follow up. */
paypalSubscriptionLeftActive: string | null
userRowDeleted: boolean
}
/**
* Irreversibly delete a user's account, data, files, and billing. Safe to call
* for an already-deleted user (it still purges storage and returns cleanly).
*/
export async function executeAccountDeletion(userId: string): Promise<DeletionOutcome> {
const outcome: DeletionOutcome = {
userId,
filesDeleted: 0,
stripeSubscription: "none",
stripeCustomer: "none",
paypalSubscriptionLeftActive: null,
userRowDeleted: false,
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, userId),
columns: {
email: true,
stripe_subscription_id: true,
stripe_customer_id: true,
paypal_subscription_id: true,
},
})
// 1. Billing — stop money moving before the data disappears.
if (profile?.stripe_subscription_id) {
try {
await stripe.subscriptions.cancel(profile.stripe_subscription_id)
outcome.stripeSubscription = "cancelled"
} catch (e) {
// Already-cancelled subscriptions throw; treat "not found"-style errors as done.
outcome.stripeSubscription = isStripeGone(e) ? "cancelled" : "error"
}
}
if (profile?.stripe_customer_id) {
try {
await stripe.customers.del(profile.stripe_customer_id)
outcome.stripeCustomer = "deleted"
} catch (e) {
outcome.stripeCustomer = isStripeGone(e) ? "deleted" : "error"
}
}
if (profile?.paypal_subscription_id) {
outcome.paypalSubscriptionLeftActive = profile.paypal_subscription_id
}
// 2. Stored files (Spaces / local disk) — outside the DB cascade.
try {
outcome.filesDeleted = await deleteUserStorage(userId)
} catch (e) {
console.error(`[gdpr] storage purge failed for ${userId}:`, e)
}
// 3. PII in FK-less compliance tables: keep the consent facts, drop identifiers.
await db
.update(consent_log)
.set({ email: null, ip_address: null, user_agent: null })
.where(eq(consent_log.user_id, userId))
// 4. Email-keyed verification tokens (password reset / email verification).
if (profile?.email) {
await db.delete(verification).where(eq(verification.identifier, profile.email))
}
// 5. The user row — cascades profiles → every portfolio table, sessions, accounts.
const deleted = await db.delete(userTable).where(eq(userTable.id, userId)).returning({ id: userTable.id })
outcome.userRowDeleted = deleted.length > 0
// Close out any open self-service request (covers admin-initiated deletes too).
await db
.update(account_deletion_requests)
.set({ status: "completed", completed_at: new Date().toISOString(), email: null })
.where(
and(
eq(account_deletion_requests.user_id, userId),
eq(account_deletion_requests.status, "pending")
)
)
// 6. Immutable evidence that the erasure ran (admin_id null = system action).
await db.insert(admin_audit_log).values({
admin_id: null,
action: "gdpr_delete_account",
target_user_id: userId,
metadata: { ...outcome },
})
return outcome
}
function isStripeGone(e: unknown): boolean {
const msg = e instanceof Error ? e.message : String(e)
return /no such|already.*cancel|resource_missing/i.test(msg)
}
/**
* Process deletion requests whose grace period has elapsed. Called by the
* daily gdpr cron. Failures stay `pending` (with the error recorded) so the
* next run retries them.
*/
export async function processDueDeletions(limit = 25): Promise<{ processed: number; deleted: number }> {
const due = await db
.select()
.from(account_deletion_requests)
.where(
and(
eq(account_deletion_requests.status, "pending"),
lte(account_deletion_requests.scheduled_for, new Date().toISOString())
)
)
.limit(limit)
let deleted = 0
for (const request of due) {
const email = request.email
try {
const outcome = await executeAccountDeletion(request.user_id)
await db
.update(account_deletion_requests)
.set({
status: "completed",
completed_at: new Date().toISOString(),
// Data minimization: the request itself must not keep PII after erasure.
email: null,
metadata: { ...request.metadata, outcome },
})
.where(eq(account_deletion_requests.id, request.id))
deleted++
if (email) {
await sendEmail({
to: email,
subject: "Your account and data have been deleted",
html: accountDeletionCompletedHtml(),
})
}
} catch (e) {
console.error(`[gdpr] deletion failed for ${request.user_id}:`, e)
await db
.update(account_deletion_requests)
.set({
metadata: {
...request.metadata,
last_error: e instanceof Error ? e.message : String(e),
last_error_at: new Date().toISOString(),
},
})
.where(eq(account_deletion_requests.id, request.id))
}
}
return { processed: due.length, deleted }
}
+186
View File
@@ -0,0 +1,186 @@
import { desc, eq, or } from "drizzle-orm"
import { db } from "@/lib/db"
import {
user as userTable,
session,
account,
profiles,
properties,
units,
tenants,
rent_payments,
maintenance_requests,
leases,
expenses,
documents,
notifications,
usage_events,
vendors,
inspections,
ai_recommendations,
ai_predictions,
follow_up_rules,
follow_up_log,
activity_log,
admin_audit_log,
account_members,
api_keys,
accounting_connections,
esign_connections,
signature_requests,
webhook_endpoints,
consent_log,
account_deletion_requests,
} from "@/lib/db/schema"
// ============================================================================
// GDPR data export (Articles 15 & 20 — access + portability).
//
// Produces a single machine-readable JSON object containing every record the
// platform stores about a user, EXCLUDING credentials and third-party secrets
// (password hashes, OAuth/API tokens, session tokens). What's excluded is
// declared in `omitted` so the export is honest about its own boundaries.
// ============================================================================
export async function buildUserDataExport(userId: string) {
const [
authUser,
profile,
sessions,
linkedAccounts,
propertyRows,
unitRows,
tenantRows,
paymentRows,
maintenanceRows,
leaseRows,
expenseRows,
documentRows,
vendorRows,
inspectionRows,
] = await Promise.all([
db.query.user.findFirst({
where: eq(userTable.id, userId),
columns: { id: true, name: true, email: true, emailVerified: true, image: true, createdAt: true },
}),
db.query.profiles.findFirst({ where: eq(profiles.id, userId) }),
db.query.session.findMany({
where: eq(session.userId, userId),
columns: { token: false }, // active credential — never exported
orderBy: desc(session.createdAt),
}),
db.query.account.findMany({
where: eq(account.userId, userId),
columns: { id: true, providerId: true, accountId: true, scope: true, createdAt: true },
}),
db.query.properties.findMany({ where: eq(properties.user_id, userId) }),
db.query.units.findMany({ where: eq(units.user_id, userId) }),
db.query.tenants.findMany({ where: eq(tenants.user_id, userId) }),
db.query.rent_payments.findMany({ where: eq(rent_payments.user_id, userId) }),
db.query.maintenance_requests.findMany({ where: eq(maintenance_requests.user_id, userId) }),
db.query.leases.findMany({ where: eq(leases.user_id, userId) }),
db.query.expenses.findMany({ where: eq(expenses.user_id, userId) }),
db.query.documents.findMany({ where: eq(documents.user_id, userId) }),
db.query.vendors.findMany({ where: eq(vendors.user_id, userId) }),
db.query.inspections.findMany({ where: eq(inspections.user_id, userId) }),
])
const [
notificationRows,
usageRows,
activityRows,
recommendationRows,
predictionRows,
followUpRuleRows,
followUpLogRows,
apiKeyRows,
webhookRows,
accountingRows,
esignRows,
signatureRows,
teamRows,
adminActionsOnUser,
consentRows,
deletionRows,
] = await Promise.all([
db.query.notifications.findMany({ where: eq(notifications.user_id, userId) }),
db.query.usage_events.findMany({ where: eq(usage_events.user_id, userId) }),
db.query.activity_log.findMany({ where: eq(activity_log.user_id, userId) }),
db.query.ai_recommendations.findMany({ where: eq(ai_recommendations.user_id, userId) }),
db.query.ai_predictions.findMany({ where: eq(ai_predictions.user_id, userId) }),
db.query.follow_up_rules.findMany({ where: eq(follow_up_rules.user_id, userId) }),
db.query.follow_up_log.findMany({ where: eq(follow_up_log.user_id, userId) }),
db.query.api_keys.findMany({
where: eq(api_keys.user_id, userId),
columns: { key_hash: false },
}),
db.query.webhook_endpoints.findMany({ where: eq(webhook_endpoints.user_id, userId) }),
db.query.accounting_connections.findMany({
where: eq(accounting_connections.user_id, userId),
columns: { access_token: false, refresh_token: false },
}),
db.query.esign_connections.findMany({
where: eq(esign_connections.user_id, userId),
columns: { access_token: false, refresh_token: false },
}),
db.query.signature_requests.findMany({ where: eq(signature_requests.user_id, userId) }),
db.query.account_members.findMany({
where: or(eq(account_members.owner_id, userId), eq(account_members.member_id, userId)),
columns: { invite_token: false },
}),
db.query.admin_audit_log.findMany({
where: eq(admin_audit_log.target_user_id, userId),
columns: { action: true, created_at: true },
}),
db.query.consent_log.findMany({ where: eq(consent_log.user_id, userId) }),
db.query.account_deletion_requests.findMany({
where: eq(account_deletion_requests.user_id, userId),
}),
])
return {
format: "propertymanagement.network/data-export",
version: 1,
generated_at: new Date().toISOString(),
omitted: [
"password hashes, OAuth/refresh tokens, session tokens, and API-key hashes (credentials are never exported)",
"webhook delivery logs (operational copies of the event records included above)",
"uploaded file BYTES — file metadata is under portfolio.documents; download the files themselves from the Documents page",
],
data_subject: { user: authUser ?? null, profile: profile ?? null },
security: { sessions, linked_sign_in_providers: linkedAccounts },
portfolio: {
properties: propertyRows,
units: unitRows,
tenants: tenantRows,
rent_payments: paymentRows,
maintenance_requests: maintenanceRows,
leases: leaseRows,
expenses: expenseRows,
documents: documentRows,
vendors: vendorRows,
inspections: inspectionRows,
},
communications: { notifications: notificationRows, follow_up_log: followUpLogRows },
automation: {
follow_up_rules: followUpRuleRows,
webhook_endpoints: webhookRows,
api_keys: apiKeyRows,
},
ai: { recommendations: recommendationRows, predictions: predictionRows },
integrations: {
accounting_connections: accountingRows,
esign_connections: esignRows,
signature_requests: signatureRows,
},
team: { memberships: teamRows },
activity: { activity_log: activityRows, usage_events: usageRows },
privacy: {
consent_log: consentRows,
deletion_requests: deletionRows,
admin_actions_affecting_you: adminActionsOnUser,
},
}
}
export type UserDataExport = Awaited<ReturnType<typeof buildUserDataExport>>
-123
View File
@@ -1,123 +0,0 @@
import { paypalFetch } from "./client"
// We encode the app user id + target plan into PayPal's `custom_id` so webhooks
// and the return handler can resolve who/what a subscription or order is for,
// without trusting query params. Format: "<userId>:<plan>".
export function encodeCustomId(userId: string, plan: string): string {
return `${userId}:${plan}`
}
export function decodeCustomId(customId: string | null | undefined): { userId: string; plan: string } | null {
if (!customId) return null
const idx = customId.lastIndexOf(":")
if (idx <= 0) return null
return { userId: customId.slice(0, idx), plan: customId.slice(idx + 1) }
}
function approveUrl(links: Array<{ rel: string; href: string }> | undefined): string | undefined {
return links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href
}
const BRAND = "Property Management Network"
/** Create a recurring subscription; returns its id + the PayPal approval URL. */
export async function createSubscription(params: {
planId: string
userId: string
plan: string
email?: string | null
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v1/billing/subscriptions", {
method: "POST",
body: JSON.stringify({
plan_id: params.planId,
custom_id: encodeCustomId(params.userId, params.plan),
subscriber: params.email ? { email_address: params.email } : undefined,
application_context: {
brand_name: BRAND,
user_action: "SUBSCRIBE_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createSubscription failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Create a one-time order (used for the Lifetime plan). */
export async function createOrder(params: {
amount: number
userId: string
plan: string
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v2/checkout/orders", {
method: "POST",
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "USD", value: params.amount.toFixed(2) },
custom_id: encodeCustomId(params.userId, params.plan),
description: `${BRAND} — Lifetime`,
},
],
application_context: {
brand_name: BRAND,
user_action: "PAY_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createOrder failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Capture an approved order. Returns the captured order (status COMPLETED). */
export async function captureOrder(orderId: string): Promise<{
status: string
custom_id?: string
} | null> {
const res = await paypalFetch(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
body: "{}",
})
if (!res.ok) return null
const json = (await res.json()) as {
status: string
purchase_units?: Array<{ custom_id?: string; payments?: { captures?: Array<{ custom_id?: string }> } }>
}
const unit = json.purchase_units?.[0]
const custom_id = unit?.custom_id ?? unit?.payments?.captures?.[0]?.custom_id
return { status: json.status, custom_id }
}
export type PaypalSubscription = {
id: string
status: string
custom_id?: string
billing_info?: { next_billing_time?: string }
}
export async function getSubscription(id: string): Promise<PaypalSubscription | null> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}`, { method: "GET" })
if (!res.ok) return null
return (await res.json()) as PaypalSubscription
}
export async function cancelSubscription(id: string, reason = "Cancelled by subscriber"): Promise<boolean> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}/cancel`, {
method: "POST",
body: JSON.stringify({ reason }),
})
// 204 = cancelled; 422 = already inactive (treat as success so the UI settles).
return res.ok || res.status === 204 || res.status === 422
}
-57
View File
@@ -1,57 +0,0 @@
// PayPal REST API client — OAuth2 client-credentials + a thin fetch helper.
//
// Enabled only when PAYPAL_CLIENT_ID and PAYPAL_SECRET are set (mirrors the
// gating used for the other optional integrations). PAYPAL_ENVIRONMENT selects
// the sandbox (default) or live host.
const ENVIRONMENT = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox"
const BASE_URL =
ENVIRONMENT === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
export function paypalConfigured(): boolean {
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET)
}
export function paypalEnvironment() {
return ENVIRONMENT
}
// Access tokens live ~9h; cache in-process (the app runs a persistent Node
// server, so this survives across requests) and refresh a minute early.
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) return cachedToken.token
const id = process.env.PAYPAL_CLIENT_ID
const secret = process.env.PAYPAL_SECRET
if (!id || !secret) throw new Error("PayPal is not configured")
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
})
if (!res.ok) throw new Error(`PayPal auth failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { access_token: string; expires_in: number }
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
return cachedToken.token
}
/** Authenticated fetch against the PayPal REST API. Path is relative (e.g. "/v1/..."). */
export async function paypalFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getAccessToken()
return fetch(`${BASE_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
})
}
-53
View File
@@ -1,53 +0,0 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import type { Plan } from "@/types"
// Applies PayPal subscription/order outcomes to a profile. Shared by the return
// handler (synchronous, on approval redirect) and the webhook (async, for
// renewals/cancellations). Both are idempotent.
const RECURRING: ReadonlyArray<Plan> = ["pro", "landlord"]
export async function fulfillSubscription(
userId: string,
plan: string,
subscriptionId: string,
nextBillingTime?: string | null,
status = "active",
): Promise<void> {
if (!RECURRING.includes(plan as Plan)) return
await db
.update(profiles)
.set({
plan: plan as Plan,
subscription_status: status,
paypal_subscription_id: subscriptionId,
billing_provider: "paypal",
plan_expires_at: nextBillingTime ?? null,
})
.where(eq(profiles.id, userId))
}
export async function fulfillLifetime(userId: string): Promise<void> {
await db
.update(profiles)
.set({ plan: "lifetime", subscription_status: "active", billing_provider: "paypal" })
.where(eq(profiles.id, userId))
}
/** Downgrade/mark a profile by its PayPal subscription id (cancel/expire/suspend). */
export async function markPaypalSubscriptionInactive(
subscriptionId: string,
status: string,
downgrade: boolean,
): Promise<void> {
await db
.update(profiles)
.set(
downgrade
? { subscription_status: status, plan: "starter", paypal_subscription_id: null, plan_expires_at: null }
: { subscription_status: status },
)
.where(eq(profiles.paypal_subscription_id, subscriptionId))
}
-28
View File
@@ -1,28 +0,0 @@
import type { Plan } from "@/types"
// PayPal billing-plan IDs, one per (plan, interval). Create them once with
// `node scripts/paypal-setup-plans.mjs` and paste the printed IDs into the
// environment. A plan/interval with no configured ID simply isn't offered.
const PAYPAL_PLAN_IDS: Record<string, string | undefined> = {
"pro:month": process.env.PAYPAL_PRO_MONTHLY_PLAN_ID,
"pro:year": process.env.PAYPAL_PRO_YEARLY_PLAN_ID,
"landlord:month": process.env.PAYPAL_LANDLORD_MONTHLY_PLAN_ID,
"landlord:year": process.env.PAYPAL_LANDLORD_YEARLY_PLAN_ID,
}
/** Recurring plans PayPal can bill (lifetime is a one-time order, not a plan). */
export const PAYPAL_RECURRING_PLANS = ["pro", "landlord"] as const
export function getPaypalPlanId(plan: Plan, interval: "month" | "year"): string | undefined {
return PAYPAL_PLAN_IDS[`${plan}:${interval}`] || undefined
}
/** True when at least one PayPal-billable plan is configured. */
export function anyPaypalPlanConfigured(): boolean {
return Object.values(PAYPAL_PLAN_IDS).some(Boolean)
}
/** Annual PayPal billing is offered only when both yearly plan IDs exist. */
export function paypalAnnualEnabled(): boolean {
return Boolean(PAYPAL_PLAN_IDS["pro:year"] && PAYPAL_PLAN_IDS["landlord:year"])
}
-36
View File
@@ -1,36 +0,0 @@
import { paypalFetch } from "./client"
// Verify an inbound PayPal webhook using PayPal's verify-webhook-signature API.
// Requires PAYPAL_WEBHOOK_ID (from the webhook you create in the PayPal app).
// Returns false (reject) when the id is missing or verification doesn't succeed.
export async function verifyPaypalWebhook(headers: Headers, rawBody: string): Promise<boolean> {
const webhookId = process.env.PAYPAL_WEBHOOK_ID
if (!webhookId) return false
let event: unknown
try {
event = JSON.parse(rawBody)
} catch {
return false
}
try {
const res = await paypalFetch("/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: headers.get("paypal-auth-algo"),
cert_url: headers.get("paypal-cert-url"),
transmission_id: headers.get("paypal-transmission-id"),
transmission_sig: headers.get("paypal-transmission-sig"),
transmission_time: headers.get("paypal-transmission-time"),
webhook_id: webhookId,
webhook_event: event,
}),
})
if (!res.ok) return false
const json = (await res.json()) as { verification_status?: string }
return json.verification_status === "SUCCESS"
} catch {
return false
}
}
+111 -1
View File
@@ -143,6 +143,53 @@ function sanitizeSegment(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
}
/**
* True iff a storage key lives in the given owner's namespace (`<ownerId>/…`).
* Keys are generated server-side as `<sanitized ownerId>/<scope>/<file>`, so any
* client-supplied key/path whose first segment differs belongs to another tenant
* (or is malformed) and must be rejected.
*/
export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean {
if (!key || !ownerId) return false
const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0]
return first === sanitizeSegment(ownerId)
}
/**
* Lightweight magic-byte check: reject a file whose real content doesn't match
* its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types
* without a reliable file signature (csv/txt) are allowed through. `head` should
* be the first ~16 bytes of the file.
*/
export function contentMatchesExtension(head: Buffer, ext: string): boolean {
const at = (offset: number, sig: number[]) =>
head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b)
switch (ext) {
case "pdf":
return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF
case "png":
return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
case "jpg":
case "jpeg":
return at(0, [0xff, 0xd8, 0xff])
case "gif":
return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8
case "webp":
return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP
case "docx":
case "xlsx":
return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK)
case "doc":
case "xls":
return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip
case "csv":
case "txt":
return true // no reliable signature
default:
return true
}
}
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
if (!body) return Buffer.alloc(0)
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
@@ -194,6 +241,36 @@ export async function saveFile(
return { key, size: file.size, type }
}
/**
* Persist raw bytes under `${userId}/${scope}/<random>.<ext>` (server-generated,
* so the key is always in the owner's namespace) and return the storage key.
* Used for server-side artifacts like signed e-sign PDFs.
*/
export async function saveBuffer(
buffer: Buffer,
opts: { userId: string; scope: string; ext: string }
): Promise<{ key: string }> {
const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}`
if (usingSpaces()) {
await s3().send(
new PutObjectCommand({
Bucket: SPACES_BUCKET,
Key: key,
Body: buffer,
ContentType: contentTypeForKey(key),
ACL: "private",
})
)
} else {
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, buffer)
}
return { key }
}
export async function readFile(key: string): Promise<Buffer> {
if (usingSpaces()) {
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
@@ -227,7 +304,10 @@ export async function presignGetUrl(
return toCdnUrl(signed)
}
export async function deleteFile(key: string): Promise<void> {
export async function deleteFile(key: string, ownerId: string): Promise<void> {
// Defense in depth: never delete an object outside the caller's own namespace,
// even if a stored storage_path was tampered with to point at another tenant.
if (!keyBelongsToOwner(key, ownerId)) return
try {
if (usingSpaces()) {
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
@@ -239,6 +319,36 @@ export async function deleteFile(key: string): Promise<void> {
}
}
/**
* Permanently delete EVERY stored object in a user's namespace (`<userId>/…`),
* across whichever backend is active. Used by GDPR account deletion — there is
* no undo. Returns the number of objects removed (best effort; local-disk
* removals aren't counted individually).
*/
export async function deleteUserStorage(userId: string): Promise<number> {
const prefix = `${sanitizeSegment(userId)}/`
if (usingSpaces()) {
let deleted = 0
let token: string | undefined
do {
const res = await s3().send(
new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token })
)
for (const obj of res.Contents ?? []) {
if (!obj.Key) continue
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: obj.Key }))
deleted++
}
token = res.IsTruncated ? res.NextContinuationToken : undefined
} while (token)
return deleted
}
await fs.rm(path.join(STORAGE_DIR, sanitizeSegment(userId)), { recursive: true, force: true })
return 0
}
async function walkDirSize(dir: string): Promise<number> {
let entries
try {
+14 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
// NOTE: The Content-Security-Policy is set per-request in `proxy.ts` (Next
// middleware) so `script-src` can carry a per-request nonce instead of
@@ -31,4 +32,16 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
org: "phluit",
project: "property-management-network",
// Only print source-map upload logs in CI.
silent: !process.env.CI,
// Upload a wider set of client source maps for readable stack traces.
widenClientFileUpload: true,
// Tree-shake Sentry's debug logger to shrink the client bundle.
disableLogger: true,
// Source-map upload runs at build time only when SENTRY_AUTH_TOKEN is set;
// without it the build still succeeds (stack traces just aren't un-minified).
});
+1855 -74
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -2,6 +2,7 @@
"name": "property-management-network",
"version": "0.1.0",
"private": true,
"license": "UNLICENSED",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -13,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",
@@ -21,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",
+108 -29
View File
@@ -28,24 +28,63 @@ const PROTECTED_PATHS = [
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
// 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
// styles and removing it would break the UI. Next.js reads the nonce from the
// `content-security-policy` request header and applies it to its own inline
// scripts automatically.
function buildCsp(nonce: string): string {
const isDev = process.env.NODE_ENV !== "production"
// 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
}
}
// In development, Next.js/React and Turbopack HMR require eval() for hot
// reloading and debugging features, and open a dev websocket. These are NOT
// added in production, where the nonce-based policy stays strict.
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"
// Origin serving the Umami analytics script (script.js) and receiving its event
// beacons (POST /api/send). Mirrors the component default so the CSP allows both
// loading the script AND sending events; stays in sync with NEXT_PUBLIC_UMAMI_SRC
// when overridden.
function umamiOrigin(): string {
const src = process.env.NEXT_PUBLIC_UMAMI_SRC || "https://fickanalytics.phluit.net/script.js"
try {
return new URL(src).origin
} catch {
return ""
}
}
// Build the Content-Security-Policy. `script-src` uses 'unsafe-inline' because
// Next.js 16's Turbopack build does NOT stamp a per-request nonce onto its
// inline hydration scripts (`self.__next_f.push(...)`). A nonce-based policy
// therefore blocks those inline scripts and the app never hydrates (blank page).
// `style-src` also keeps 'unsafe-inline' (Radix / Tailwind / framer-motion inject
// inline styles). NOTE: to restore the stricter nonce-based script policy, build
// with webpack (`next build --webpack`) so Next applies the nonce to its scripts.
function buildCsp(): string {
const isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin()
// Dev additionally needs 'unsafe-eval' (Turbopack HMR) plus a dev websocket
// (added to connect-src below).
const umami = umamiOrigin()
const scriptSrc = [
"script-src 'self' 'unsafe-inline'",
isDev ? "'unsafe-eval'" : "",
"https://challenges.cloudflare.com",
umami, // load the Umami analytics script
]
.filter(Boolean)
.join(" ")
const connectSrc = [
"connect-src 'self'",
isDev ? "ws: wss:" : "",
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
umami, // Umami event beacons (POST /api/send)
sentry ?? "",
]
.filter(Boolean)
.join(" ")
return [
"default-src 'self'",
@@ -54,13 +93,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,26 +141,40 @@ export async function proxy(request: NextRequest) {
}
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
let dropStaleSessionCookie = false
if (isAuthPage && sessionCookie) {
const state = await sessionState(request)
if (state === "valid") {
const url = request.nextUrl.clone()
url.pathname = "/dashboard"
return NextResponse.redirect(url)
}
// Dead cookie (session revoked or expired): render the auth page and drop
// the cookie below so protected paths stop treating this visitor as
// signed in. On "unknown", render the page but keep the cookie.
dropStaleSessionCookie = state === "invalid"
}
// Per-request CSP nonce. UUID contains only hex + dashes, so it never
// includes HTML-escape characters (which Next rejects in nonces).
const nonce = crypto.randomUUID()
const csp = buildCsp(nonce)
const csp = buildCsp()
// Forward the nonce + CSP on the request headers so Next.js can pick up the
// nonce and apply it to its own inline scripts during render.
const requestHeaders = new Headers(request.headers)
requestHeaders.set("x-nonce", nonce)
requestHeaders.set("Content-Security-Policy", csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Also set the CSP on the outgoing response so the browser enforces it.
const response = NextResponse.next()
// Set the CSP on the outgoing response so the browser enforces it.
response.headers.set("Content-Security-Policy", csp)
if (dropStaleSessionCookie) {
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
for (const cookie of request.cookies.getAll()) {
if (!cookie.name.includes("better-auth.session_token")) continue
response.cookies.set(cookie.name, "", {
maxAge: 0,
path: "/",
httpOnly: true,
sameSite: "lax",
secure: cookie.name.startsWith("__Secure-"),
})
}
}
return response
}
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

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

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Some files were not shown because too many files have changed in this diff Show More