diff --git a/.do/app.yaml b/.do/app.yaml new file mode 100644 index 0000000..907a92b --- /dev/null +++ b/.do/app.yaml @@ -0,0 +1,172 @@ +# ───────────────────────────────────────────────────────────────────────────── +# DigitalOcean App Platform spec — Property Management Network +# +# Deploy: doctl apps create --spec .do/app.yaml +# Update: doctl apps update --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. +# +# 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. +# ───────────────────────────────────────────────────────────────────────────── +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 + instance_count: 1 + instance_size_slug: apps-s-1vcpu-1gb + http_port: 3000 + health_check: + http_path: /api/health + initial_delay_seconds: 20 + period_seconds: 30 + timeout_seconds: 5 + success_threshold: 1 + 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. + - key: NEXT_PUBLIC_APP_URL + scope: RUN_TIME + value: ${APP_URL} + - key: BETTER_AUTH_URL + scope: RUN_TIME + value: ${APP_URL} + - key: NEXT_PUBLIC_APP_NAME + scope: RUN_TIME + value: Property Management Network + + # ── Database (managed Postgres — use the PRIVATE host; see DIGITALOCEAN.md) ── + - 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. + - key: DATABASE_SSL + scope: RUN_TIME + value: require + - key: DATABASE_CA + 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. + - key: RUN_MIGRATIONS_ON_START + scope: RUN_TIME + value: "false" + + # ── Better Auth ─────────────────────────────────────────────────────── + - key: BETTER_AUTH_SECRET + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + - key: GOOGLE_CLIENT_ID + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + - key: GOOGLE_CLIENT_SECRET + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + + # ── Stripe ──────────────────────────────────────────────────────────── + - key: STRIPE_SECRET_KEY + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + - key: STRIPE_WEBHOOK_SECRET + 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 + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + + # ── 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 + - key: SMTP_PORT + scope: RUN_TIME + value: "2525" + - key: SMTP_USER + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + - key: SMTP_PASS + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + - key: EMAIL_FROM + 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) ── + - key: NEXT_PUBLIC_TURNSTILE_SITE_KEY + scope: RUN_TIME + value: 0x4AAAAAADuDQverznfv1a60 + - key: TURNSTILE_SECRET_KEY + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + + # ── Object storage (DigitalOcean Spaces + CDN) ──────────────────────── + - key: SPACES_KEY + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + - key: SPACES_SECRET + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD + - key: SPACES_REGION + scope: RUN_TIME + value: nyc3 + - key: SPACES_BUCKET + scope: RUN_TIME + value: property-management-network + - key: SPACES_ENDPOINT + scope: RUN_TIME + value: https://nyc3.digitaloceanspaces.com + - key: SPACES_CDN_ENDPOINT + scope: RUN_TIME + value: https://nyc3.cdn.digitaloceanspaces.com + + # ── Cron (Bearer token the DO Function sends to /api/cron/*) ── + - key: CRON_SECRET + scope: RUN_TIME + type: SECRET + value: REPLACE_IN_DASHBOARD diff --git a/.dockerignore b/.dockerignore index e35593a..458937b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,6 @@ coverage # Secrets — never bake env files into the image .env .env.* -supabase # Local file storage (uploads live on a mounted volume, not in the image) storage @@ -17,7 +16,6 @@ storage .git .gitignore .gitattributes -.vercel *.tsbuildinfo # Editor / OS noise diff --git a/.env.example b/.env.example index 9246bd1..70863e0 100644 --- a/.env.example +++ b/.env.example @@ -18,31 +18,109 @@ BETTER_AUTH_URL=http://localhost:3000 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# === STORAGE (local disk) === -# Directory where uploaded files are stored (kept out of the public web root). +# === ADMIN BOOTSTRAP & AUTH POLICY === +# Comma-separated Better Auth user IDs and/or emails granted /admin access. +# Set at least one to administer the platform. No self-service admin path exists. +ADMIN_USER_IDS= +ADMIN_EMAILS= +# Require a verified email before sign-in (recommended for production). +REQUIRE_EMAIL_VERIFICATION=false + +# === STORAGE === +# Local-disk fallback directory (used only when Spaces below is not configured). STORAGE_DIR=./storage +# Object storage (DigitalOcean Spaces, S3-compatible). When all of KEY/SECRET/ +# BUCKET are set, uploads and file serving use the bucket instead of local disk. +# Keep the bucket PRIVATE — files are served through the auth-gated /api/files route. +SPACES_KEY= +SPACES_SECRET= +SPACES_REGION=nyc3 +SPACES_BUCKET= +SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com +# Optional: if the Space's CDN is enabled, presigned URLs serve from the edge. +SPACES_CDN_ENDPOINT=https://nyc3.cdn.digitaloceanspaces.com + # === STRIPE === # Get from: https://dashboard.stripe.com/apikeys STRIPE_SECRET_KEY=sk_test_your-secret-key STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key +# No price IDs needed — the app resolves prices by stable lookup keys and +# 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 -# Stripe Price IDs — create in Stripe Dashboard > Products -STRIPE_PRO_MONTHLY_PRICE_ID=price_your-pro-monthly-id -STRIPE_LANDLORD_MONTHLY_PRICE_ID=price_your-landlord-monthly-id -STRIPE_LIFETIME_PRICE_ID=price_your-lifetime-id +# === 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 +# /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 OPENAI_API_KEY=sk-your-api-key -# === EMAIL (Resend) === -# Get from: https://resend.com/api-keys -RESEND_API_KEY=re_your-api-key -RESEND_FROM_EMAIL=noreply@yourdomain.com +# === EMAIL (SMTP — e.g. SMTP2GO) === +# Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS. +SMTP_HOST=mail.smtp2go.com +SMTP_PORT=2525 +SMTP_USER= +SMTP_PASS= +EMAIL_FROM=postmaster@yourdomain.com + +# === ACCOUNTING SYNC (optional — QuickBooks / Xero OAuth) === +# Create developer apps and set the redirect URI to +# /api/integrations/quickbooks/callback and .../xero/callback +# Leave blank to hide/disable a provider. Tokens are encrypted at rest. +QBO_CLIENT_ID= +QBO_CLIENT_SECRET= +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 → /api/esign/docusign/webhook ; Dropbox Sign callback → +# /api/esign/dropbox_sign/webhook +DOCUSIGN_ACCESS_TOKEN= +DOCUSIGN_ACCOUNT_ID= +DOCUSIGN_BASE_URI=https://demo.docusign.net # === APP === NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_NAME=Property Management Network +# Google Search Console verification token (Search Console -> Settings -> HTML tag). +# Leave blank in dev; set in production to emit the verification meta tag. +GOOGLE_SITE_VERIFICATION= CRON_SECRET=your-random-secret-string + +# === ANALYTICS (Umami — optional) === +# Cookieless page analytics, loaded ONLY in production builds. Defaults point at +# the shared phluit Umami instance; override to use a different tracker, or set +# the website id to empty to disable. Both are public (they appear in the HTML). +NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js +NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 + +# === 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 +# identifying User-Agent — set this to a contact URL or email for production. +GEOCODER_USER_AGENT=PropertyManagementNetwork/1.0 (https://propertymanagement.network) + +# === CLOUDFLARE TURNSTILE (bot protection on auth forms) === +# Create a widget at https://dash.cloudflare.com/?to=/:account/turnstile +# Leave both blank to disable the captcha (auth forms still work). +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= diff --git a/.env.production.example b/.env.production.example index 55ca038..4f6b019 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,21 +1,20 @@ # ============================================================================ # PROPERTY MANAGEMENT NETWORK — Production Environment # ============================================================================ -# Set these in Coolify (Environment Variables). Do NOT commit real values. +# Set these in DigitalOcean App Platform (Environment Variables). Do NOT commit real values. # # Build-time vs runtime: -# NEXT_PUBLIC_* are inlined into the browser bundle during `next build`, so -# they MUST also be set as Build Variables in Coolify (not just runtime). +# NEXT_PUBLIC_* are inlined into the browser bundle at image build time, so +# they must be passed as --build-arg when building the image (see DIGITALOCEAN.md). # ============================================================================ # === DATABASE (PostgreSQL) === -# Coolify Postgres (internal): postgres://USER:PASSWORD@:5432/DB -DATABASE_URL=postgres://user:password@db:5432/pmn +# DO Managed Postgres (private host, direct port 25060): postgres://USER:PASSWORD@:25060/dbname +DATABASE_URL=postgres://user:password@private-host:25060/dbname # TLS policy (app + migrations). Default is encrypted + certificate-verified. -# disable -> no TLS. Use for Coolify's internal/private-network Postgres -# and the bundled docker-compose DB (plaintext over a private net). -# no-verify -> encrypted but unverified (self-signed certs). +# disable -> no TLS (local / unix-socket development only). +# no-verify -> encrypted but unverified — use for DO Managed Postgres (or supply DATABASE_CA). # require -> encrypted + verified (managed DBs with a public CA). # DATABASE_CA -> optional custom CA cert (PEM) when verifying. DATABASE_SSL=require @@ -35,28 +34,113 @@ BETTER_AUTH_URL=https://propertymanagement.network GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# === STORAGE (local disk — mount a persistent volume on this path) === +# === ADMIN BOOTSTRAP & AUTH POLICY === +# Grant admin access to the /admin dashboard. Comma-separated Better Auth user +# IDs and/or emails. Set at least ONE on a fresh deploy or no one can administer +# the platform. There is no self-service path to become an admin (by design). +ADMIN_USER_IDS= +ADMIN_EMAILS= +# Require a verified email address before a user can sign in. Strongly +# recommended in production (defaults to false / disabled if unset). +REQUIRE_EMAIL_VERIFICATION=true + +# === STORAGE === +# Local-disk fallback (used only when Spaces below is NOT configured). If you use +# Spaces you no longer need the persistent volume, but keeping it is harmless. STORAGE_DIR=/app/storage +# Object storage (DigitalOcean Spaces, S3-compatible) — recommended for prod. +# When KEY/SECRET/BUCKET are all set, uploads + serving use the bucket. Keep the +# bucket PRIVATE; files are served through the auth-gated /api/files route. +SPACES_KEY= +SPACES_SECRET= +SPACES_REGION=nyc3 +SPACES_BUCKET=property-management-network +SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com +# Optional: if the Space's CDN is enabled, presigned URLs serve from the edge. +SPACES_CDN_ENDPOINT=https://nyc3.cdn.digitaloceanspaces.com + # === STRIPE === STRIPE_SECRET_KEY=sk_live_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx -STRIPE_PRO_MONTHLY_PRICE_ID=price_xxx -STRIPE_LANDLORD_MONTHLY_PRICE_ID=price_xxx -STRIPE_LIFETIME_PRICE_ID=price_xxx +# No price IDs needed — prices are resolved by lookup key and auto-created on +# 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 +# /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 -# === EMAIL (Resend) === -RESEND_API_KEY=re_xxx -RESEND_FROM_EMAIL=noreply@propertymanagement.network +# === EMAIL (SMTP — SMTP2GO) === +SMTP_HOST=mail.smtp2go.com +SMTP_PORT=2525 +SMTP_USER= +SMTP_PASS= +EMAIL_FROM=postmaster@propertymanagement.network + +# === ACCOUNTING SYNC (optional — QuickBooks / Xero OAuth) === +# Redirect URIs: /api/integrations/quickbooks/callback and .../xero/callback +# QBO_ENVIRONMENT=production for live QuickBooks. Tokens are encrypted at rest +# (AES-256-GCM) with a key derived from BETTER_AUTH_SECRET. +QBO_CLIENT_ID= +QBO_CLIENT_SECRET= +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 → /api/esign/dropbox_sign/webhook +# DocuSign Connect → /api/esign/docusign/webhook +# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs. +DROPBOX_SIGN_API_KEY= +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 NEXT_PUBLIC_APP_NAME=Property Management Network +# Google Search Console verification token (Search Console -> Settings -> HTML tag). +GOOGLE_SITE_VERIFICATION= + +# === ANALYTICS (Umami) === +# Cookieless page analytics, loaded only in production. These NEXT_PUBLIC_* vars +# are inlined at build time — set them as Build Variables in DO App Platform. +# Set the website id to empty to disable. Both values are public. +NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js +NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 + +# === 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 — +# set a real contact URL/email so they can reach you if there's a usage issue. +GEOCODER_USER_AGENT=PropertyManagementNetwork/1.0 (https://propertymanagement.network) # === CRON === # Bearer token required by the /api/cron/* and /api/follow-ups/run endpoints. CRON_SECRET=replace-with-a-random-string + +# === CLOUDFLARE TURNSTILE (bot protection on auth forms) === +# Create a widget at https://dash.cloudflare.com/?to=/:account/turnstile +# NEXT_PUBLIC_TURNSTILE_SITE_KEY is inlined at build time — also set it as a +# Build Variable in Coolify. Leave both blank to disable the captcha. +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= diff --git a/.gitignore b/.gitignore index 570156a..d466acc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +*.crt # local file storage (uploaded documents/photos) /storage @@ -38,18 +39,10 @@ yarn-error.log* !.env.example !.env.production.example -# Legacy Supabase check script — contains a hardcoded service_role key. -# Excluded from version control; rotate that key and delete this file. -supabase/verify.mjs - -# vercel -.vercel - # typescript *.tsbuildinfo next-env.d.ts .env.local DOCS/ -.vercel .env*.local diff --git a/COOLIFY.md b/COOLIFY.md deleted file mode 100644 index 4ba4f24..0000000 --- a/COOLIFY.md +++ /dev/null @@ -1,127 +0,0 @@ -# Deploying Property Management Network on Coolify - -This app is a Next.js 16 (App Router) server that needs: - -- a **PostgreSQL** database, -- a **persistent volume** for uploaded files (documents/photos are stored on local disk under `STORAGE_DIR`), -- a few third-party API keys (Stripe, OpenAI, Resend), -- **scheduled tasks** for the rent/lease cron jobs (Coolify replaces `vercel.json` crons). - -The repo ships a production `Dockerfile` (standalone output), a `/api/health` liveness probe, and an entrypoint that runs database migrations on boot. - -There are two ways to deploy. **Path A (Dockerfile + separate Postgres) is recommended.** - ---- - -## Path A — Dockerfile build pack + Coolify Postgres (recommended) - -### 1. Create the database -In your Coolify project: **+ New → Database → PostgreSQL**. Once created, copy its **internal connection string** (looks like `postgres://postgres:PASSWORD@:5432/postgres`). Use the internal host — the app talks to it over Coolify's private network. - -### 2. Create the application -**+ New → Application → Public/Private Git Repository**, point it at this repo, and set **Build Pack = Dockerfile**. - -### 3. Set environment variables -Under the app's **Environment Variables**, add everything from [`.env.production.example`](.env.production.example). At minimum: - -| Variable | Notes | -|---|---| -| `DATABASE_URL` | Internal Postgres URL from step 1. | -| `DATABASE_SSL` | TLS policy. Use `disable` for Coolify's internal/private-network Postgres; `require` (default) for managed/external DBs; `no-verify` for self-signed certs. | -| `BETTER_AUTH_SECRET` | `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | -| `BETTER_AUTH_URL` | Your public URL, e.g. `https://propertymanagement.network` | -| `NEXT_PUBLIC_APP_URL` | Same public URL. **Also mark as a Build Variable** (see below). | -| `NEXT_PUBLIC_APP_NAME` | `Property Management Network` (Build Variable too). | -| `CRON_SECRET` | Random string; protects the cron endpoints. | -| `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | Email sending. | -| `STRIPE_*` | Billing (optional to start). | -| `OPENAI_API_KEY` | AI assistant (optional to start). | - -> **Build Variables:** `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_APP_NAME` are inlined into the browser bundle at build time. In Coolify, set them so they're **available at build** (toggle "Build Variable" / "Available at Buildtime"). They're passed to the image via `ARG`/`--build-arg`. - -### 4. Add a persistent volume for uploads -Uploaded files are written to `STORAGE_DIR` (default `/app/storage`). Without a volume they're lost on every redeploy. - -Under the app's **Storages → Add**: mount a persistent volume at the container path **`/app/storage`**. - -### 5. Domain & port -- Set the app's **Domain** to your URL; Coolify provisions HTTPS automatically. -- The container listens on **port 3000** (already `EXPOSE`d). Coolify usually detects this; set the port to `3000` if asked. - -### 6. Health check -The image has a built-in Docker `HEALTHCHECK` hitting `/api/health`. You can also set Coolify's health check path to `/api/health`. - -### 7. Deploy -Click **Deploy**. On boot the entrypoint runs `scripts/migrate.mjs` to apply migrations, then starts the server. Watch the deploy logs for `[migrate] Migrations applied successfully.` followed by the Next.js ready line. - ---- - -## Path B — Docker Compose (app + Postgres bundled) - -Use the included [`docker-compose.yml`](docker-compose.yml) with Coolify's **Docker Compose** build pack. It defines the `app` and a `db` (Postgres 17) plus named volumes `app-storage` and `db-data`. - -Set these env vars in Coolify (mark `NEXT_PUBLIC_*` and `POSTGRES_*` as available at build time): - -``` -POSTGRES_USER=pmn -POSTGRES_PASSWORD= -POSTGRES_DB=pmn -BETTER_AUTH_SECRET= -BETTER_AUTH_URL=https://your-domain -NEXT_PUBLIC_APP_URL=https://your-domain -NEXT_PUBLIC_APP_NAME=Property Management Network -CRON_SECRET= -RESEND_API_KEY=... # plus STRIPE_*, OPENAI_API_KEY as needed -``` - -`DATABASE_URL` is composed automatically from the `POSTGRES_*` values inside the compose file. The app waits for the DB healthcheck before starting and migrations retry while Postgres comes up. - ---- - -## Database migrations - -Migrations live in `lib/db/migrations` (Drizzle). They run automatically on container start via the entrypoint. - -- To **disable** auto-migrate (e.g. when running more than one replica), set `RUN_MIGRATIONS_ON_START=false` and run them as a one-off instead: - ```sh - # From a Coolify terminal/exec into the container: - node scripts/migrate.mjs - ``` - ---- - -## Scheduled tasks (cron) - -Coolify does not read `vercel.json`. Recreate the two jobs under the app's **Scheduled Tasks**. Each runs a command inside the container; authenticate with the `CRON_SECRET` env var that's already present there. - -| Name | Schedule (UTC) | Command | -|---|---|---| -| Daily (rent reminders, overdue, lease expiry) | `0 9 * * *` | `wget -q -O- --header="Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/daily` | -| Late fees | `0 8 * * *` | `wget -q -O- --header="Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/late-fees` | - -(The `daily` route already combines rent reminders, overdue marking, and 60/30/7-day lease-expiry emails.) - ---- - -## Stripe webhook (if using billing) - -Point a Stripe webhook at `https:///api/stripe/webhook` and put its signing secret in `STRIPE_WEBHOOK_SECRET`. Subscribe to: `checkout.session.completed`, `customer.subscription.created/updated/deleted`, `invoice.payment_failed`, `payment_intent.succeeded`. - ---- - -## Post-deploy checklist - -- [ ] `https:///api/health` returns `{"status":"ok",...}` -- [ ] Home page shows **Property Management Network** branding -- [ ] Sign up / log in works (verifies `DATABASE_URL` + `BETTER_AUTH_*`) -- [ ] Upload a document, redeploy, confirm it persists (verifies the `/app/storage` volume) -- [ ] Trigger the `daily` scheduled task manually and confirm a 200 in logs -- [ ] (If billing) Stripe webhook delivers successfully - ---- - -## Notes - -- **Google OAuth:** set `GOOGLE_CLIENT_ID/SECRET` and add `/api/auth/callback/google` as an authorized redirect URI. -- **Scaling:** with more than one replica, disable per-instance auto-migration (`RUN_MIGRATIONS_ON_START=false`) and note that local-disk storage is per-container — move uploads to object storage (e.g. S3) if you scale horizontally. -- **TLS:** the app and migrator default to encrypted + certificate-verified Postgres connections. Set `DATABASE_SSL=disable` for Coolify's internal private-network Postgres (and the bundled compose DB), `require` for managed/external DBs with a public CA, or `no-verify` for self-signed certs (optionally supply `DATABASE_CA`). diff --git a/DIGITALOCEAN.md b/DIGITALOCEAN.md new file mode 100644 index 0000000..6bffabd --- /dev/null +++ b/DIGITALOCEAN.md @@ -0,0 +1,182 @@ +# Deploying Property Management Network on DigitalOcean App Platform + +This app runs as a single Next.js 16 (standalone) container. On App Platform it needs: + +- a **Managed PostgreSQL** database, +- **DigitalOcean Spaces** (S3-compatible) for uploads — App Platform containers are + **ephemeral**, so local-disk storage would be wiped on every deploy (the app already + uses Spaces; see `SPACES_*` env vars), +- **DigitalOcean Functions** for the two daily cron jobs (App Platform has no native cron), +- a few third-party keys/credentials (Stripe, OpenAI, SMTP/SMTP2GO, Cloudflare Turnstile). + +Because the source repo lives on self-hosted **Gitea** (which App Platform can't pull), +the app is deployed as a **pre-built image from DigitalOcean Container Registry (DOCR)**. + +--- + +## 0. Prerequisites + +- `doctl` installed and authenticated (`doctl auth init`) +- A DOCR registry: `doctl registry create ` (once) +- A Managed PostgreSQL cluster (see step 1) +- The Space `property-management-network` in `nyc3` with CDN enabled (already set up) + +### Optional: manage this app with the DigitalOcean MCP server + +For natural-language management of App Platform, the database cluster, and Spaces +from Claude Code (deploy status, logs, env vars), register the DigitalOcean MCP +server. It's a **management convenience only** — deploys still go through DOCR + +`doctl`, migrations through `scripts/migrate-prod.mjs`, and cron through DO Functions. + +1. Create a **scoped** DO API token (console → **API → Tokens**) limited to the + resources used here: App Platform (read + write), Databases (read), Spaces + (read). Do **not** use a full-access token. +2. Register it locally — the token is stored in `~/.claude.json`, never in the repo: + ```bash + claude mcp add digitalocean --scope local \ + -e DIGITALOCEAN_API_TOKEN= \ + -- npx -y @digitalocean/mcp --services apps,databases,spaces + ``` +3. Reconnect the Claude Code session (`/mcp`); `claude mcp list` should then show + `digitalocean` connected. Remove anytime with `claude mcp remove digitalocean`. + +--- + +## 1. Database + +Create a **Managed PostgreSQL** cluster and a database (e.g. `propertymanagementnetwork`). + +**Privileges:** the migrator and the app both run DDL (create the `drizzle` schema + +tables, and apply migrations on boot). A database owned by `doadmin` does **not** grant +DDL to a scoped user automatically. Either: + +- use the **`doadmin`** user in `DATABASE_URL`, **or** +- grant your scoped user the needed rights (run once as `doadmin`): + ```sql + GRANT ALL ON DATABASE propertymanagementnetwork TO propertymanagementnetworksuser; + \c propertymanagementnetwork + GRANT ALL ON SCHEMA public TO propertymanagementnetworksuser; + ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT ALL ON TABLES TO propertymanagementnetworksuser; + ``` + +**Host & port:** use the **private** host (`private-...db.ondigitalocean.com`) in the +app's `DATABASE_URL` — the app runs inside DO's network, so it's faster and not publicly +exposed. Use the **public** host only for one-off admin/migration from your laptop. Use +the **direct port `25060`** (not the `25061` connection pool) so `migrate-on-boot` and its +advisory locks work correctly. + +**Trusted Sources:** on the DB cluster → **Settings → Trusted Sources**, add the App +Platform app (and, temporarily, your laptop's IP for the initial migration). Otherwise the +cluster's firewall refuses connections. + +**SSL (verified TLS — recommended):** DO's DB cert isn't in the system trust store, so +verification against the system CAs fails. Use verify-full instead: **omit `?sslmode=...` +from `DATABASE_URL`**, keep `DATABASE_SSL=require`, and set `DATABASE_CA` to the cluster's +CA cert — DB cluster → **Connection Details → Download CA certificate**, then paste the PEM +contents as the `DATABASE_CA` secret. With `require` and no (or an invalid) CA the app +**refuses to connect** rather than run unverified — that's intended. `DATABASE_SSL=no-verify` +(encrypted but unverified) exists only as an emergency fallback; do not use it in production. + +--- + +## 2. Build and push the image to DOCR + +`NEXT_PUBLIC_*` values are inlined into the browser bundle at **build time**, so pass +them as `--build-arg`. Use the URL you'll actually serve on (your custom domain, or the +`*.ondigitalocean.app` URL once known): + +```bash +doctl registry login # auth Docker to DOCR + +REG=registry.digitalocean.com/ +docker build \ + --build-arg NEXT_PUBLIC_APP_URL=https:// \ + --build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \ + --build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \ + -t $REG/property-management-network:latest . + +docker push $REG/property-management-network:latest +``` + +> 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`. + +--- + +## 3. Create the app + +```bash +doctl apps create --spec .do/app.yaml +``` + +Then set every `type: SECRET` value (App → Settings → Environment Variables), or edit +`.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 +(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. + +Migrations do **not** run on boot (`RUN_MIGRATIONS_ON_START=false`) — the app user has no +DDL rights by design. Apply schema changes out-of-band as **doadmin** before/after +deploying, from a machine allowed by the DB's Trusted Sources: + +```bash +DATABASE_URL="postgresql://doadmin:@:25060/propertymanagementnetwork" \ + DATABASE_SSL=no-verify node scripts/migrate.mjs +``` + +The migrator is idempotent (only pending migrations run). The current schema (0000–0002) +is already applied to production. Redeploys reuse the same image tag — App Platform pulls +the new `:latest` on push (`deploy_on_push`). + +--- + +## 4. Cron — DigitalOcean Functions + +The two jobs are triggered by DO Functions schedulers hitting the app's protected +endpoints. Create `functions/.env` (gitignored): + +``` +APP_BASE_URL=https:// +CRON_SECRET= +``` + +Deploy: + +```bash +doctl serverless install # once +doctl serverless connect # once, pick/create a namespace +doctl serverless deploy functions --env functions/.env +``` + +This registers `cron/run` with two scheduler triggers: `daily` at `0 9 * * *` and +`late-fees` at `0 8 * * *` (UTC). Verify in **Functions → Triggers**. + +--- + +## 5. Stripe webhook + +Point a Stripe webhook at `https:///api/stripe/webhook` and put its signing +secret in `STRIPE_WEBHOOK_SECRET`. Subscribe to: `checkout.session.completed`, +`customer.subscription.created/updated/deleted`, `invoice.payment_failed`, +`payment_intent.succeeded`. + +--- + +## 6. Google OAuth (optional) + +Set `GOOGLE_CLIENT_ID/SECRET` and add `https:///api/auth/callback/google` +as an authorized redirect URI. + +--- + +## Post-deploy checklist + +- [ ] `https:///api/health` returns `{"status":"ok",...}` +- [ ] Sign up / log in works (verifies `DATABASE_URL` + `BETTER_AUTH_*` + Turnstile) +- [ ] Upload a document; confirm the object appears in the Space and serves via the CDN +- [ ] Trigger the `daily` function manually (`doctl serverless functions invoke cron/run -p job:daily`) → 200 +- [ ] Stripe webhook delivers successfully diff --git a/Dockerfile b/Dockerfile index eba9be7..3020201 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # ───────────────────────────────────────────────────────────────────────────── -# Property Management Network — production image for Coolify / Docker +# Property Management Network — production image for DigitalOcean App Platform (DOCR) # Multi-stage build producing a slim Next.js standalone server. # ───────────────────────────────────────────────────────────────────────────── @@ -20,11 +20,13 @@ FROM base AS builder ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 # NEXT_PUBLIC_* values are inlined into the client bundle at build time, so they -# must be present here. Pass them as build args from Coolify (Build Variables). +# must be present here. Pass them as --build-arg when building the image. ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_APP_NAME="Property Management Network" +ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY 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 COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build @@ -59,7 +61,7 @@ RUN mkdir -p /app/storage && chown -R nextjs:nodejs /app/storage USER nextjs EXPOSE 3000 -# Liveness probe (also wired into Coolify). Uses Node's global fetch — no curl/wget needed. +# Liveness probe (also used by App Platform health checks). Uses Node's global fetch — no curl/wget needed. HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" diff --git a/README.md b/README.md index 8032476..dfc6000 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- - Property Management Network + + Property Management Network

@@ -9,7 +9,7 @@ **Property management SaaS for independent landlords.** Track properties, tenants, rent, maintenance, leases, and expenses — all in one clean dashboard. -Built with Next.js 16, PostgreSQL (Drizzle ORM), Better Auth, Stripe, and OpenAI. Ready to deploy on Vercel in under 10 minutes. +Built with Next.js 16, PostgreSQL (Drizzle ORM), Better Auth, Stripe, and OpenAI. Deploys to DigitalOcean App Platform (see [DIGITALOCEAN.md](DIGITALOCEAN.md)). --- @@ -25,7 +25,7 @@ Property Management Network replaces the spreadsheet + WhatsApp chaos that most - **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 Resend +- **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 --- @@ -51,12 +51,12 @@ Subscription billing via Stripe. Lifetime deal is ideal for Flippa buyers who wa | Styling | Tailwind CSS + Geist font | | Database | PostgreSQL (via Drizzle ORM) | | Auth | Better Auth (email/password + Google OAuth) | -| Storage | Local disk (auth-gated file serving) | +| Storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) | | Payments | Stripe (subscriptions + payment links) | | AI | OpenAI (gpt-4o-mini) | -| Email | Resend | -| Cron | Vercel Cron Jobs | -| Deploy | Vercel | +| Email | SMTP (SMTP2GO) | +| Cron | DigitalOcean Functions (scheduled triggers) | +| Deploy | DigitalOcean App Platform (Docker image via DOCR) | --- @@ -91,20 +91,20 @@ GOOGLE_CLIENT_SECRET= # File storage (local disk) STORAGE_DIR=./storage -# Stripe +# 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= -STRIPE_PRO_MONTHLY_PRICE_ID= -STRIPE_LANDLORD_MONTHLY_PRICE_ID= -STRIPE_LIFETIME_PRICE_ID= # OpenAI OPENAI_API_KEY= -# Resend -RESEND_API_KEY= -RESEND_FROM_EMAIL=Property Management Network +# 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 @@ -123,10 +123,7 @@ To regenerate migrations after changing the schema, use `npm run db:generate`. F ### 4. Configure Stripe -Create three products in your Stripe dashboard: -- **Pro Monthly** — $29/mo recurring → copy Price ID to `STRIPE_PRO_MONTHLY_PRICE_ID` -- **Landlord Monthly** — $59/mo recurring → copy Price ID to `STRIPE_LANDLORD_MONTHLY_PRICE_ID` -- **Lifetime** — $199 one-time → copy Price ID to `STRIPE_LIFETIME_PRICE_ID` +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` @@ -136,9 +133,9 @@ Set up a webhook at `https://yourdomain.com/api/stripe/webhook` listening to: - `invoice.payment_failed` - `payment_intent.succeeded` -### 5. Configure Resend +### 5. Configure email (SMTP) -Add a verified sending domain in your Resend dashboard. Update `RESEND_FROM_EMAIL` with your domain address. +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 @@ -152,11 +149,9 @@ npm run dev Open [http://localhost:3000](http://localhost:3000). -### 8. Deploy to Vercel +### 8. Deploy (DigitalOcean App Platform) -Connect the repo in the Vercel dashboard and add all environment variables under **Settings → Environment Variables**. - -Cron jobs are pre-configured in `vercel.json` and run automatically on Vercel. +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. --- @@ -184,7 +179,7 @@ app/ │ ├── expenses/ # CRUD │ ├── documents/ # Document metadata (files on local disk) │ ├── ai/ # Rent receipts + maintenance summaries -│ ├── notifications/ # Send emails via Resend +│ ├── notifications/ # Send emails via SMTP (SMTP2GO) │ ├── stripe/ # Checkout, portal, webhook │ └── cron/ # Rent reminders + lease expiry alerts └── tenant-portal/[token]/ # Public tenant portal (no login) @@ -195,7 +190,7 @@ lib/ ├── storage.ts # Local-disk file storage helpers ├── stripe/ # Client, plans, payment links ├── ai/ # OpenAI client + prompts -├── email/ # Resend client + HTML templates +├── email/ # SMTP (SMTP2GO) client + HTML templates └── validations/ # Zod schemas for all entities drizzle.config.ts # Drizzle ORM config (DATABASE_URL, migrations dir) diff --git a/SECURITY.md b/SECURITY.md index 4a8f04c..07d9d0a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,7 +3,7 @@ > **⚠️ TREAT ALL SECRETS IN `.env.local` AS COMPROMISED.** > This project was distributed in a transfer package, which means every secret > that was present in `.env.local` — the `DATABASE_URL` / Postgres password, -> `BETTER_AUTH_SECRET`, and any Stripe / OpenAI / Resend API keys — has left a +> `BETTER_AUTH_SECRET`, and any Stripe / OpenAI / SMTP credentials — has left a > trusted boundary and **must be treated as leaked**. Rotate **all** of them > before any production deployment or client handoff. Do not assume "it was only > a zip" — assume the file is public. @@ -26,14 +26,14 @@ ever appeared in the distributed `.env.local`. - [ ] **Stripe** — roll the secret key (and restricted keys), and rotate the webhook signing secret in the Stripe Dashboard. - [ ] **OpenAI** — revoke the leaked API key and issue a new one. -- [ ] **Resend** — revoke the leaked API key and issue a new one. +- [ ] **SMTP (SMTP2GO)** — rotate the SMTP password / credentials. - [ ] **`CRON_SECRET`** — set a strong random value (the cron routes now fail closed if it is unset): ```bash node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` - Configure the same value in Vercel so Cron sends - `Authorization: Bearer `. + Configure the same value in your host's env (e.g. DigitalOcean App Platform) + so the scheduled tasks send `Authorization: Bearer `. - [ ] **Google OAuth** — if the client secret was present in the transfer, rotate it in the Google Cloud Console. @@ -42,7 +42,7 @@ ever appeared in the distributed `.env.local`. - [ ] **Never commit `.env.local`** (or any real `.env*` with live values). Confirm it is listed in `.gitignore`. - [ ] Store production secrets in the deployment platform's encrypted env-var - store (e.g. Vercel Project Settings → Environment Variables), not in files. + store (e.g. DigitalOcean App Platform → Environment Variables), not in files. - [ ] Use distinct secrets per environment (dev / preview / production). ## 3. Database / TLS @@ -64,7 +64,7 @@ The following hardening has already been applied in this codebase: `require`), with optional `DATABASE_CA`. - **Constant-time cron auth** — `lib/cron-auth.ts` performs a `timingSafeEqual` bearer-token comparison that **fails closed** when `CRON_SECRET` is unset. All - cron routes (`daily`, `late-fees`, `lease-expiry`, `rent-reminders`) now use it + cron routes (`daily`, `late-fees`, `follow-ups`) now use it and share the standard `Authorization: Bearer` scheme. - **Security headers** — `next.config.ts` sets a strict baseline on all routes: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` diff --git a/app/(admin)/admin/system/page.tsx b/app/(admin)/admin/system/page.tsx index 9280dd1..d107050 100644 --- a/app/(admin)/admin/system/page.tsx +++ b/app/(admin)/admin/system/page.tsx @@ -1,4 +1,6 @@ import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries" +import { getMaintenanceMode } from "@/lib/settings" +import { MaintenanceToggle } from "@/components/admin/maintenance-toggle" import { formatDate } from "@/lib/utils" import { Settings, Database, Table2 } from "lucide-react" @@ -11,9 +13,10 @@ function humanize(name: string): string { } export default async function AdminSystemPage() { - const [{ counts, cronLastRun }, env] = await Promise.all([ + const [{ counts, cronLastRun }, env, maintenance] = await Promise.all([ getSystemCounts(), Promise.resolve(getEnvHealth()), + getMaintenanceMode(), ]) return ( @@ -26,6 +29,14 @@ export default async function AdminSystemPage() {

+ {/* Maintenance mode control */} +
+ +
+
{/* ── Environment configuration ───────────────────────────────── */}
diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index 3fbeb0e..72d2173 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -1,5 +1,6 @@ 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 default async function ForgotPasswordPage({ @@ -49,6 +50,8 @@ export default async function ForgotPasswordPage({ />
+ + -

{MONTHS[month]} {year}

- -
- - {/* Day headers */} -
- {DAYS.map((d) => ( -
- {d} -
- ))} -
- - {/* Days */} -
- {cells.map((day, i) => { - const key = day ? dateKey(day) : null - const events = key ? (eventsByDate[key] ?? []) : [] - const isToday = key === todayKey - const isSelected = key === selected - const paymentEvents = events.filter((e) => e.type === "payment") - const leaseEvents = events.filter((e) => e.type === "lease") - +
+ {/* Toolbar: filters + subscribe */} +
+
+ {(Object.keys(TYPE_META) as EventType[]).map((t) => { + const m = TYPE_META[t] + const on = active[t] return ( -
day && key && setSelected(isSelected ? null : key)} - className={cn( - "relative min-h-[72px] border-b border-r border-white/[0.03] p-1.5 transition-colors", - day ? "cursor-pointer hover:bg-white/[0.03]" : "opacity-0 pointer-events-none", - isSelected && "bg-indigo-600/10 border-indigo-500/20", - )} +
+ + {m.label} + ) })}
+
- {/* Side panel */} -
-
-

- {selected ? new Date(selected + "T12:00:00").toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }) : "Select a date"} -

-
- - {!selected ? ( -
-

Click any day to see events

-
- ) : selectedEvents.length === 0 ? ( -
-

No events this day

-
+ {/* Calendar */} +
+ {mounted ? ( + ) : ( -
- {selectedEvents.map((e, i) => ( -
-
- {e.type === "payment" ? : } -
-
- {e.type === "payment" ? ( - <> -

{e.item.tenant?.first_name} {e.item.tenant?.last_name}

-

{formatCurrency(e.item.amount)} due

- - {e.item.status} - - - ) : ( - <> -

Lease Expiry

-

{e.item.tenant?.first_name} {e.item.tenant?.last_name}

-

{e.item.property?.name}

- - )} -
-
- ))} -
+
Loading calendar…
)} +
- {/* Legend */} -
-

Legend

- {[ - { color: "bg-indigo-500/20 text-indigo-400", label: "Rent pending" }, - { color: "bg-emerald-500/20 text-emerald-400", label: "Rent paid" }, - { color: "bg-red-500/20 text-red-400", label: "Rent overdue" }, - { color: "bg-amber-500/20 text-amber-400", label: "Lease expiry" }, - ].map((l) => ( -
-
- {l.label} + {selected && setSelected(null)} />} + {showSubscribe && setShowSubscribe(false)} />} +
+ ) +} + +function EventDetail({ ev, onClose }: { ev: CalEvent["extendedProps"] & { title: string; start: string }; onClose: () => void }) { + const m = TYPE_META[ev.type] + const dateLabel = new Date(ev.start + "T12:00:00").toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }) + return ( +
+
e.stopPropagation()}> +
+
+
+
- ))} +
+

{ev.title}

+

{m.label}

+
+
+
+
+
Date
{dateLabel}
+ {ev.subtitle &&
Details
{ev.subtitle}
} + {ev.status &&
Status
{ev.status}
} +
+ + Open {m.label.toLowerCase()} + +
+
+ ) +} + +function SubscribeModal({ url, onClose }: { url: string; onClose: () => void }) { + const [copied, setCopied] = useState(false) + const webcal = url.replace(/^https?:\/\//, "webcal://") + return ( +
+
e.stopPropagation()}> +
+
+

Subscribe to your calendar

+

One-way sync — new rent, lease, and inspection dates appear automatically in your calendar app.

+
+ +
+ + {url ? ( + <> +
+ e.currentTarget.select()} /> + +
+ +
+

Google Calendar: Settings → Add calendar → From URL → paste the link.

+

Apple Calendar: File → New Calendar Subscription → paste, or open this webcal link.

+

Outlook: Add calendar → Subscribe from web → paste the link.

+
+ +
+

Keep this link private — anyone with it can view your dates.

+ Download .ics +
+ + ) : ( +

Your feed link isn't available yet. Refresh the page and try again.

+ )}
) diff --git a/app/(dashboard)/calendar/calendar.css b/app/(dashboard)/calendar/calendar.css new file mode 100644 index 0000000..a8ab6ae --- /dev/null +++ b/app/(dashboard)/calendar/calendar.css @@ -0,0 +1,147 @@ +/* Dark theme for FullCalendar, scoped to .fc-dark to match the app's aesthetic. */ +.fc-dark { + --fc-border-color: rgba(255, 255, 255, 0.06); + --fc-page-bg-color: transparent; + --fc-neutral-bg-color: rgba(255, 255, 255, 0.02); + --fc-neutral-text-color: rgba(255, 255, 255, 0.5); + --fc-today-bg-color: rgba(99, 102, 241, 0.1); + --fc-now-indicator-color: #6366f1; + --fc-list-event-hover-bg-color: rgba(255, 255, 255, 0.04); + --fc-highlight-color: rgba(99, 102, 241, 0.15); +} + +.fc-dark .fc { + color: rgba(255, 255, 255, 0.85); + font-family: inherit; +} + +/* Toolbar */ +.fc-dark .fc .fc-toolbar.fc-header-toolbar { + margin-bottom: 1rem; + flex-wrap: wrap; + gap: 0.5rem; +} +.fc-dark .fc .fc-toolbar-title { + font-size: 1.05rem; + font-weight: 700; + color: #fff; +} +.fc-dark .fc .fc-button { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + box-shadow: none; + text-transform: none; + font-size: 0.78rem; + font-weight: 500; + padding: 0.35rem 0.7rem; + border-radius: 0.55rem; +} +.fc-dark .fc .fc-button:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.14); +} +.fc-dark .fc .fc-button:focus, +.fc-dark .fc .fc-button:active { + box-shadow: none !important; + outline: none; +} +.fc-dark .fc .fc-button-primary:not(:disabled).fc-button-active, +.fc-dark .fc .fc-button-primary:not(:disabled):active { + background: #4f46e5; + border-color: #4f46e5; + color: #fff; +} +.fc-dark .fc .fc-button:disabled { + opacity: 0.4; +} +.fc-dark .fc .fc-button-group > .fc-button { + margin: 0; +} + +/* Grid headers + cells */ +.fc-dark .fc .fc-col-header-cell-cushion { + color: rgba(255, 255, 255, 0.4); + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.6rem 0.4rem; +} +.fc-dark .fc .fc-daygrid-day-number { + color: rgba(255, 255, 255, 0.5); + font-size: 0.78rem; + padding: 0.4rem 0.5rem; +} +.fc-dark .fc .fc-day-today .fc-daygrid-day-number { + color: #a5b4fc; + font-weight: 700; +} +.fc-dark .fc .fc-daygrid-day.fc-day-other { + background: rgba(255, 255, 255, 0.012); +} +.fc-dark .fc .fc-daygrid-day.fc-day-other .fc-daygrid-day-number { + opacity: 0.35; +} + +/* Events */ +.fc-dark .fc .fc-event { + border-radius: 6px; + border: none; + font-size: 0.72rem; + font-weight: 500; + padding: 1px 5px; + cursor: pointer; +} +.fc-dark .fc .fc-event:hover { + filter: brightness(1.12); +} +.fc-dark .fc .fc-daygrid-event .fc-event-title { + font-weight: 500; +} +.fc-dark .fc .fc-daygrid-more-link { + color: rgba(255, 255, 255, 0.4); + font-size: 0.68rem; + font-weight: 600; +} +.fc-dark .fc .fc-daygrid-more-link:hover { + color: #fff; +} +.fc-dark .fc .fc-popover { + background: #16161f; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.75rem; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); +} +.fc-dark .fc .fc-popover-header { + background: rgba(255, 255, 255, 0.04); + color: #fff; +} + +/* Time grid (week/day) */ +.fc-dark .fc .fc-timegrid-slot-label-cushion, +.fc-dark .fc .fc-timegrid-axis-cushion { + color: rgba(255, 255, 255, 0.35); + font-size: 0.68rem; +} + +/* List / agenda view */ +.fc-dark .fc .fc-list { + border-color: var(--fc-border-color); +} +.fc-dark .fc .fc-list-day-cushion { + background: rgba(255, 255, 255, 0.03); + color: #fff; +} +.fc-dark .fc .fc-list-event:hover td { + background: rgba(255, 255, 255, 0.04); +} +.fc-dark .fc .fc-list-event-title, +.fc-dark .fc .fc-list-event-time { + color: rgba(255, 255, 255, 0.8); +} +.fc-dark .fc .fc-list-empty { + background: transparent; + color: rgba(255, 255, 255, 0.3); +} diff --git a/app/(dashboard)/calendar/page.tsx b/app/(dashboard)/calendar/page.tsx index 010cdac..6c9632c 100644 --- a/app/(dashboard)/calendar/page.tsx +++ b/app/(dashboard)/calendar/page.tsx @@ -1,52 +1,125 @@ import { redirect } from "next/navigation" import { and, eq, gte, lte } from "drizzle-orm" import { db } from "@/lib/db" -import { rent_payments, leases } from "@/lib/db/schema" +import { profiles, rent_payments, leases, inspections } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" -import { CalendarClient } from "./calendar-client" +import { getAccountContext } from "@/lib/account" +import { CalendarClient, type CalEvent } from "./calendar-client" export const metadata = { title: "Calendar" } +const RENT_COLOR = (s: string) => + s === "paid" ? "#10b981" : s === "overdue" ? "#ef4444" : "#6366f1" +const LEASE_COLOR = "#f59e0b" +const INSPECTION_COLOR = "#14b8a6" + export default async function CalendarPage() { const user = await getSessionUser() if (!user) redirect("/login") - const now = new Date() - const rangeStart = new Date(now.getFullYear(), now.getMonth() - 1, 1) - const rangeEnd = new Date(now.getFullYear(), now.getMonth() + 3, 0) + const ctx = await getAccountContext(user.id) + const ownerId = ctx.ownerId - const [payments, leaseList] = await Promise.all([ + const now = new Date() + const rangeStart = new Date(now.getFullYear(), now.getMonth() - 3, 1) + const rangeEnd = new Date(now.getFullYear(), now.getMonth() + 12, 0) + const iso = (d: Date) => d.toISOString().slice(0, 10) + + const [payments, leaseList, inspectionList, profile] = await Promise.all([ db.query.rent_payments.findMany({ where: and( - eq(rent_payments.user_id, user.id), - gte(rent_payments.due_date, rangeStart.toISOString().slice(0, 10)), - lte(rent_payments.due_date, rangeEnd.toISOString().slice(0, 10)) + eq(rent_payments.user_id, ownerId), + gte(rent_payments.due_date, iso(rangeStart)), + lte(rent_payments.due_date, iso(rangeEnd)) ), columns: { id: true, due_date: true, amount: true, status: true }, - with: { - tenant: { columns: { first_name: true, last_name: true } }, - }, + with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } }, unit: { columns: { unit_number: true } } }, }), db.query.leases.findMany({ - where: and( - eq(leases.user_id, user.id), - gte(leases.lease_end, new Date().toISOString().slice(0, 10)) - ), + where: and(eq(leases.user_id, ownerId), eq(leases.status, "active")), columns: { id: true, lease_end: true }, - with: { - tenant: { columns: { first_name: true, last_name: true } }, - property: { columns: { name: true } }, - }, + with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } } }, }), + db.query.inspections.findMany({ + where: and(eq(inspections.user_id, ownerId), gte(inspections.date, iso(rangeStart)), lte(inspections.date, iso(rangeEnd))), + columns: { id: true, date: true, type: true, status: true }, + with: { property: { columns: { name: true } }, unit: { columns: { unit_number: true } } }, + }), + db.query.profiles.findFirst({ where: eq(profiles.id, ownerId), columns: { calendar_token: true } }), ]) + const events: CalEvent[] = [ + ...payments.map((p) => { + const who = `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`.trim() || "Tenant" + const color = RENT_COLOR(p.status) + return { + id: `rent-${p.id}`, + title: `${who} · $${Number(p.amount).toLocaleString("en-US")}`, + start: p.due_date!, + allDay: true, + backgroundColor: color, + borderColor: color, + editable: ctx.canWrite, + extendedProps: { + type: "rent" as const, + entityId: p.id, + status: p.status, + subtitle: `${p.property?.name ?? ""}${p.unit ? ` · Unit ${p.unit.unit_number}` : ""}`, + href: "/rent", + }, + } + }), + ...leaseList.map((l) => { + const who = `${l.tenant?.first_name ?? ""} ${l.tenant?.last_name ?? ""}`.trim() || "Tenant" + return { + id: `lease-${l.id}`, + title: `Lease ends: ${who}`, + start: l.lease_end!, + allDay: true, + backgroundColor: LEASE_COLOR, + borderColor: LEASE_COLOR, + editable: false, + extendedProps: { + type: "lease" as const, + entityId: l.id, + subtitle: l.property?.name ?? "", + href: `/leases/${l.id}`, + }, + } + }), + ...inspectionList.map((ins) => { + const label = ins.type.replace("_", "-") + return { + id: `insp-${ins.id}`, + title: `${label} inspection`, + start: ins.date!, + allDay: true, + backgroundColor: INSPECTION_COLOR, + borderColor: INSPECTION_COLOR, + editable: ctx.canWrite, + extendedProps: { + type: "inspection" as const, + entityId: ins.id, + status: ins.status, + subtitle: `${ins.property?.name ?? ""}${ins.unit ? ` · Unit ${ins.unit.unit_number}` : ""}`, + href: "/inspections", + }, + } + }), + ] + + const base = process.env.NEXT_PUBLIC_APP_URL ?? "" + const subscribeUrl = profile?.calendar_token ? `${base}/api/calendar/${profile.calendar_token}.ics` : "" + return ( -
+

Calendar

-

Rent due dates and lease expirations at a glance

+

+ Rent due dates, lease expirations, and inspections — subscribe to sync with Google, Apple, or Outlook. +

- +
) } diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 9da88ba..13447cb 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm" import { db } from "@/lib/db" import { profiles } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { getDashboardStats, getRecentRentPayments, @@ -33,20 +34,11 @@ function GreetingBanner({ name }: { name: string }) { const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }) return ( -
-
-

- {greeting}, {name?.split(" ")[0] ?? "there"} 👋 -

-

{dateStr}

-
-
-
- - -
- All systems operational -
+
+

+ {greeting}, {name?.split(" ")[0] ?? "there"} 👋 +

+

{dateStr}

) } @@ -96,23 +88,32 @@ export default async function DashboardPage() { const user = await getSessionUser() if (!user) redirect("/login") - // Fetch profile for greeting + // Data is scoped to the effective owner's portfolio (team access). + const ownerId = await getEffectiveOwnerId(user.id) + + // Fetch profile for greeting (the logged-in user's own name / onboarding state). const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id), - columns: { full_name: true }, + columns: { full_name: true, onboarding_completed: true }, }) const [stats, recentPayments, openMaintenance, expiringLeases, monthlyRevenue, expenseBreakdown] = await Promise.all([ - getDashboardStats(user.id), - getRecentRentPayments(user.id), - getOpenMaintenanceRequests(user.id), - getExpiringLeases(user.id), - getMonthlyRevenue(user.id), - getExpenseBreakdown(user.id), + getDashboardStats(ownerId), + getRecentRentPayments(ownerId), + getOpenMaintenanceRequests(ownerId), + getExpiringLeases(ownerId), + getMonthlyRevenue(ownerId), + getExpenseBreakdown(ownerId), ]) const hasData = stats.totalProperties > 0 + // New users who haven't finished onboarding and have no data go through the + // dedicated onboarding flow first. + if (!(profile as { onboarding_completed?: boolean })?.onboarding_completed && !hasData) { + redirect("/onboarding") + } + const rentTrendPct = stats.rentCollectedLastMonth > 0 ? Math.round(((stats.rentCollectedThisMonth - stats.rentCollectedLastMonth) / stats.rentCollectedLastMonth) * 100) : null diff --git a/app/(dashboard)/expenses/new/page.tsx b/app/(dashboard)/expenses/new/page.tsx index b965283..50a655c 100644 --- a/app/(dashboard)/expenses/new/page.tsx +++ b/app/(dashboard)/expenses/new/page.tsx @@ -3,6 +3,7 @@ import { asc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { ExpenseForm } from "@/components/forms/expense-form" import { BackButton } from "@/components/ui/back-button" @@ -12,8 +13,10 @@ export default async function NewExpensePage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const propertyList = await db.query.properties.findMany({ - where: eq(properties.user_id, user.id), + where: eq(properties.user_id, ownerId), columns: { id: true, name: true }, with: { units: { columns: { id: true, unit_number: true } }, diff --git a/app/(dashboard)/expenses/page.tsx b/app/(dashboard)/expenses/page.tsx index b6cddcc..a3a13f0 100644 --- a/app/(dashboard)/expenses/page.tsx +++ b/app/(dashboard)/expenses/page.tsx @@ -3,6 +3,7 @@ import { asc, desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { expenses, properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { ExpensesClient } from "./expenses-client" export const metadata = { title: "Expenses" } @@ -11,9 +12,11 @@ export default async function ExpensesPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const [expenseList, propertyList] = await Promise.all([ db.query.expenses.findMany({ - where: eq(expenses.user_id, user.id), + where: eq(expenses.user_id, ownerId), with: { property: { columns: { name: true } }, unit: { columns: { unit_number: true } }, @@ -23,7 +26,7 @@ export default async function ExpensesPage() { db .select({ id: properties.id, name: properties.name }) .from(properties) - .where(eq(properties.user_id, user.id)) + .where(eq(properties.user_id, ownerId)) .orderBy(asc(properties.name)), ]) diff --git a/app/(dashboard)/follow-ups/page.tsx b/app/(dashboard)/follow-ups/page.tsx index ad4a986..9b0a620 100644 --- a/app/(dashboard)/follow-ups/page.tsx +++ b/app/(dashboard)/follow-ups/page.tsx @@ -3,6 +3,7 @@ import { asc, desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { follow_up_rules, follow_up_log } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { FollowUpsClient } from "./follow-ups-client" export const metadata = { title: "Automated Follow-ups" } @@ -11,16 +12,18 @@ export default async function FollowUpsPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const [rules, logs] = await Promise.all([ db .select() .from(follow_up_rules) - .where(eq(follow_up_rules.user_id, user.id)) + .where(eq(follow_up_rules.user_id, ownerId)) .orderBy(asc(follow_up_rules.created_at)), db .select() .from(follow_up_log) - .where(eq(follow_up_log.user_id, user.id)) + .where(eq(follow_up_log.user_id, ownerId)) .orderBy(desc(follow_up_log.created_at)) .limit(30), ]) diff --git a/app/(dashboard)/impact/page.tsx b/app/(dashboard)/impact/page.tsx index c8364b1..ce16c85 100644 --- a/app/(dashboard)/impact/page.tsx +++ b/app/(dashboard)/impact/page.tsx @@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { ai_recommendations, activity_log } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { ImpactClient } from "./impact-client" export const metadata = { title: "AI Impact" } @@ -11,15 +12,17 @@ export default async function ImpactPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const [recs, activityRows] = await Promise.all([ db .select() .from(ai_recommendations) - .where(eq(ai_recommendations.user_id, user.id)), + .where(eq(ai_recommendations.user_id, ownerId)), db .select() .from(activity_log) - .where(and(eq(activity_log.user_id, user.id), eq(activity_log.type, "ai_action"))) + .where(and(eq(activity_log.user_id, ownerId), eq(activity_log.type, "ai_action"))) .orderBy(desc(activity_log.created_at)) .limit(20), ]) diff --git a/app/(dashboard)/inspections/inspection-manager.tsx b/app/(dashboard)/inspections/inspection-manager.tsx index 8bf1967..10f2109 100644 --- a/app/(dashboard)/inspections/inspection-manager.tsx +++ b/app/(dashboard)/inspections/inspection-manager.tsx @@ -19,8 +19,8 @@ const typeColors: Record = { } const statusIcon: Record = { - draft: Clock, - completed: CheckCircle2, + draft: Clock, + complete: CheckCircle2, } export function InspectionManager({ inspections: initial, properties }: { inspections: any[]; properties: any[] }) { @@ -44,7 +44,7 @@ export function InspectionManager({ inspections: initial, properties }: { inspec const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition" async function toggleStatus(id: string, current: string) { - const next = current === "completed" ? "draft" : "completed" + const next = current === "complete" ? "draft" : "complete" const res = await fetch(`/api/inspections/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -52,7 +52,7 @@ export function InspectionManager({ inspections: initial, properties }: { inspec }) if (res.ok) { setInspections(v => v.map(i => i.id === id ? { ...i, status: next } : i)) - toast.success(next === "completed" ? "Marked complete" : "Marked draft") + toast.success(next === "complete" ? "Marked complete" : "Marked draft") } } @@ -161,9 +161,9 @@ export function InspectionManager({ inspections: initial, properties }: { inspec + +
+ ) +} diff --git a/app/(dashboard)/predictions/page.tsx b/app/(dashboard)/predictions/page.tsx index a4e0ad4..d2cf03e 100644 --- a/app/(dashboard)/predictions/page.tsx +++ b/app/(dashboard)/predictions/page.tsx @@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { ai_predictions } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { PredictionsClient } from "./predictions-client" export const metadata = { title: "Predictive Analytics" } @@ -11,10 +12,12 @@ export default async function PredictionsPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const predictions = await db .select() .from(ai_predictions) - .where(eq(ai_predictions.user_id, user.id)) + .where(eq(ai_predictions.user_id, ownerId)) .orderBy(desc(ai_predictions.created_at)) return diff --git a/app/(dashboard)/properties/[propertyId]/edit/page.tsx b/app/(dashboard)/properties/[propertyId]/edit/page.tsx index 284f56c..a38c143 100644 --- a/app/(dashboard)/properties/[propertyId]/edit/page.tsx +++ b/app/(dashboard)/properties/[propertyId]/edit/page.tsx @@ -3,15 +3,18 @@ import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { PropertyForm } from "@/components/forms/property-form" export default async function EditPropertyPage({ params }: { params: Promise<{ propertyId: string }> }) { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const { propertyId } = await params const property = await db.query.properties.findFirst({ - where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)), + where: and(eq(properties.id, propertyId), eq(properties.user_id, ownerId)), }) if (!property) notFound() diff --git a/app/(dashboard)/properties/[propertyId]/page.tsx b/app/(dashboard)/properties/[propertyId]/page.tsx index 675b14b..cfc2753 100644 --- a/app/(dashboard)/properties/[propertyId]/page.tsx +++ b/app/(dashboard)/properties/[propertyId]/page.tsx @@ -3,6 +3,7 @@ import { and, eq, gte } from "drizzle-orm" import { db } from "@/lib/db" import { properties as propertiesTable, rent_payments, expenses as expensesTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import Link from "next/link" import { MapPin, Plus, BedDouble, Bath, Edit } from "lucide-react" import { formatCurrency, getOccupancyRate } from "@/lib/utils" @@ -10,11 +11,15 @@ import { DeletePropertyButton } from "@/components/forms/delete-property-button" import { AiMaintenanceSummary } from "@/components/forms/ai-maintenance-summary" import { PropertyRevenueChart } from "@/components/dashboard/property-revenue-chart" import { PropertyPhotoUpload } from "@/components/forms/property-photo-upload" +import { UnitActions } from "@/components/forms/unit-actions" +import { PropertyMap } from "@/components/maps/property-map" export default async function PropertyDetailPage({ params }: { params: Promise<{ propertyId: string }> }) { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const { propertyId } = await params const sixMonthsAgo = new Date() @@ -24,7 +29,7 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{ const [property, payments, expenses] = await Promise.all([ db.query.properties.findFirst({ - where: and(eq(propertiesTable.id, propertyId), eq(propertiesTable.user_id, user.id)), + where: and(eq(propertiesTable.id, propertyId), eq(propertiesTable.user_id, ownerId)), with: { units: { with: { @@ -38,7 +43,7 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{ .from(rent_payments) .where( and( - eq(rent_payments.user_id, user.id), + eq(rent_payments.user_id, ownerId), eq(rent_payments.property_id, propertyId), gte(rent_payments.due_date, rangeStart) ) @@ -48,7 +53,7 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{ .from(expensesTable) .where( and( - eq(expensesTable.user_id, user.id), + eq(expensesTable.user_id, ownerId), eq(expensesTable.property_id, propertyId), gte(expensesTable.expense_date, rangeStart) ) @@ -167,8 +172,11 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{ )}
-
-

{formatCurrency(unit.rent_amount)}/mo

+
+
+

{formatCurrency(unit.rent_amount)}/mo

+
+
))} @@ -176,6 +184,28 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{ )}
+ {/* Location */} + {property.latitude != null && property.longitude != null && ( +
+
+ +

Location

+
+ +
+ )} + {/* Revenue chart */} diff --git a/app/(dashboard)/properties/[propertyId]/units/[unitId]/edit/page.tsx b/app/(dashboard)/properties/[propertyId]/units/[unitId]/edit/page.tsx new file mode 100644 index 0000000..80678be --- /dev/null +++ b/app/(dashboard)/properties/[propertyId]/units/[unitId]/edit/page.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation" +import { and, eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { properties, units } from "@/lib/db/schema" +import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" +import { BackButton } from "@/components/ui/back-button" +import { UnitForm } from "@/components/forms/unit-form" + +export const metadata = { title: "Edit Unit" } + +export default async function EditUnitPage({ params }: { params: Promise<{ propertyId: string; unitId: string }> }) { + const user = await getSessionUser() + if (!user) redirect("/login") + + const ownerId = await getEffectiveOwnerId(user.id) + + const { propertyId, unitId } = await params + + const [property, unit] = await Promise.all([ + db.query.properties.findFirst({ + where: and(eq(properties.id, propertyId), eq(properties.user_id, ownerId)), + columns: { id: true, name: true }, + }), + db.query.units.findFirst({ + where: and(eq(units.id, unitId), eq(units.property_id, propertyId), eq(units.user_id, ownerId)), + }), + ]) + + if (!property || !unit) redirect(`/properties/${propertyId}`) + + return ( +
+ +
+

Edit Unit {unit.unit_number}

+

{property.name}

+
+
+ +
+
+ ) +} diff --git a/app/(dashboard)/properties/[propertyId]/units/new/page.tsx b/app/(dashboard)/properties/[propertyId]/units/new/page.tsx index 52b959f..aa5477a 100644 --- a/app/(dashboard)/properties/[propertyId]/units/new/page.tsx +++ b/app/(dashboard)/properties/[propertyId]/units/new/page.tsx @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { BackButton } from "@/components/ui/back-button" import { UnitForm } from "@/components/forms/unit-form" @@ -12,10 +13,12 @@ export default async function NewUnitPage({ params }: { params: Promise<{ proper const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const { propertyId } = await params const property = await db.query.properties.findFirst({ - where: and(eq(properties.id, propertyId), eq(properties.user_id, user.id)), + where: and(eq(properties.id, propertyId), eq(properties.user_id, ownerId)), columns: { id: true, name: true }, }) diff --git a/app/(dashboard)/properties/page.tsx b/app/(dashboard)/properties/page.tsx index 638ce34..562c021 100644 --- a/app/(dashboard)/properties/page.tsx +++ b/app/(dashboard)/properties/page.tsx @@ -3,9 +3,11 @@ import { eq, desc } from "drizzle-orm" import { db } from "@/lib/db" import { properties as propertiesTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import Link from "next/link" import { Building2, MapPin, BedDouble, ArrowRight, TrendingUp } from "lucide-react" import { EmptyState } from "@/components/shared/empty-state" +import { PropertyMap } from "@/components/maps/property-map" import { formatCurrency, getOccupancyRate } from "@/lib/utils" export const metadata = { title: "Properties" } @@ -14,8 +16,10 @@ export default async function PropertiesPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const properties = await db.query.properties.findMany({ - where: eq(propertiesTable.user_id, user.id), + where: eq(propertiesTable.user_id, ownerId), with: { units: { columns: { id: true, status: true, rent_amount: true } }, }, @@ -26,6 +30,17 @@ export default async function PropertiesPage() { return sum + (p.units ?? []).filter((u: any) => u.status === "occupied").reduce((s: number, u: any) => s + Number(u.rent_amount), 0) }, 0) + const mapMarkers = (properties ?? []) + .filter((p: any) => p.latitude != null && p.longitude != null) + .map((p: any) => ({ + id: p.id, + name: p.name, + lat: p.latitude as number, + lng: p.longitude as number, + subtitle: `${p.address_line1 ? `${p.address_line1}, ` : ""}${p.city}${p.state ? `, ${p.state}` : ""}`, + href: `/properties/${p.id}`, + })) + return (
{/* Page header */} @@ -39,6 +54,12 @@ export default async function PropertiesPage() {
+ {mapMarkers.length > 0 && ( +
+ +
+ )} + {!properties?.length ? ( diff --git a/app/(dashboard)/rent/generate/page.tsx b/app/(dashboard)/rent/generate/page.tsx index aa4b717..e893d7e 100644 --- a/app/(dashboard)/rent/generate/page.tsx +++ b/app/(dashboard)/rent/generate/page.tsx @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { leases as leasesTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { BulkGenerateForm } from "./bulk-generate-form" export const metadata = { title: "Generate Rent" } @@ -11,8 +12,10 @@ export default async function GenerateRentPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const leases = await db.query.leases.findMany({ - where: and(eq(leasesTable.user_id, user.id), eq(leasesTable.status, "active")), + where: and(eq(leasesTable.user_id, ownerId), eq(leasesTable.status, "active")), columns: { id: true, rent_amount: true }, with: { tenant: { columns: { first_name: true, last_name: true } }, diff --git a/app/(dashboard)/rent/import/page.tsx b/app/(dashboard)/rent/import/page.tsx index b51548c..af744a3 100644 --- a/app/(dashboard)/rent/import/page.tsx +++ b/app/(dashboard)/rent/import/page.tsx @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { tenants as tenantsTable, properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { BackButton } from "@/components/ui/back-button" import { RentCsvImport } from "./rent-csv-import" @@ -12,8 +13,10 @@ export default async function ImportRentPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const tenants = await db.query.tenants.findMany({ - where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")), + where: and(eq(tenantsTable.user_id, ownerId), eq(tenantsTable.status, "active")), columns: { id: true, first_name: true, last_name: true }, with: { unit: { columns: { unit_number: true } }, @@ -24,7 +27,7 @@ export default async function ImportRentPage() { const properties_ = await db .select({ id: properties.id, name: properties.name }) .from(properties) - .where(eq(properties.user_id, user.id)) + .where(eq(properties.user_id, ownerId)) return (
diff --git a/app/(dashboard)/rent/new/page.tsx b/app/(dashboard)/rent/new/page.tsx index 17449b5..e74fcf0 100644 --- a/app/(dashboard)/rent/new/page.tsx +++ b/app/(dashboard)/rent/new/page.tsx @@ -3,6 +3,7 @@ import { and, asc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { tenants as tenantsTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { RentPaymentForm } from "@/components/forms/rent-payment-form" import { BackButton } from "@/components/ui/back-button" @@ -12,8 +13,10 @@ export default async function NewRentPaymentPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const tenants = await db.query.tenants.findMany({ - where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")), + where: and(eq(tenantsTable.user_id, ownerId), eq(tenantsTable.status, "active")), columns: { id: true, first_name: true, last_name: true, property_id: true, unit_id: true }, with: { unit: { columns: { unit_number: true, rent_amount: true } }, diff --git a/app/(dashboard)/rent/page.tsx b/app/(dashboard)/rent/page.tsx index f6772b0..e48596e 100644 --- a/app/(dashboard)/rent/page.tsx +++ b/app/(dashboard)/rent/page.tsx @@ -3,6 +3,7 @@ import { eq, desc } from "drizzle-orm" import { db } from "@/lib/db" import { rent_payments } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import Link from "next/link" import { CreditCard, Plus, Upload } from "lucide-react" import { EmptyState } from "@/components/shared/empty-state" @@ -15,8 +16,10 @@ export default async function RentPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const payments = await db.query.rent_payments.findMany({ - where: eq(rent_payments.user_id, user.id), + where: eq(rent_payments.user_id, ownerId), with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } }, diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index dcfefa5..d4406c1 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -3,9 +3,11 @@ import { and, eq, gte } from "drizzle-orm" import { db } from "@/lib/db" import { properties as propertiesTable, rent_payments, expenses as expensesTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { formatCurrency } from "@/lib/utils" import { TrendingUp, TrendingDown, Building2, DollarSign, Receipt, BarChart3 } from "lucide-react" import { ReportsClient } from "./reports-client" +import { CsvExportButton } from "@/components/forms/csv-export-button" export const metadata = { title: "Reports" } @@ -13,6 +15,8 @@ export default async function ReportsPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + // Last 6 months range const sixMonthsAgo = new Date() sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5) @@ -23,7 +27,7 @@ export default async function ReportsPage() { db .select({ id: propertiesTable.id, name: propertiesTable.name }) .from(propertiesTable) - .where(eq(propertiesTable.user_id, user.id)), + .where(eq(propertiesTable.user_id, ownerId)), db .select({ amount: rent_payments.amount, @@ -32,7 +36,7 @@ export default async function ReportsPage() { property_id: rent_payments.property_id, }) .from(rent_payments) - .where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, rangeStart))), + .where(and(eq(rent_payments.user_id, ownerId), gte(rent_payments.due_date, rangeStart))), db .select({ amount: expensesTable.amount, @@ -41,7 +45,7 @@ export default async function ReportsPage() { category: expensesTable.category, }) .from(expensesTable) - .where(and(eq(expensesTable.user_id, user.id), gte(expensesTable.expense_date, rangeStart))), + .where(and(eq(expensesTable.user_id, ownerId), gte(expensesTable.expense_date, rangeStart))), ]) // Build monthly buckets for last 6 months @@ -79,6 +83,19 @@ export default async function ReportsPage() { return (
+ {/* Header + CSV exports */} +
+
+

Reports

+

Revenue, expenses & profit — last 6 months

+
+
+ + + +
+
+ {/* Summary KPI cards */}
{[ diff --git a/app/(dashboard)/settings/api-keys/page.tsx b/app/(dashboard)/settings/api-keys/page.tsx new file mode 100644 index 0000000..796a03f --- /dev/null +++ b/app/(dashboard)/settings/api-keys/page.tsx @@ -0,0 +1,48 @@ +import { redirect } from "next/navigation" +import Link from "next/link" +import { desc, eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { api_keys } from "@/lib/db/schema" +import { getSessionUser } from "@/lib/session" +import { ApiKeyManager, type ApiKeyRow } from "@/components/dashboard/api-key-manager" + +export const metadata = { title: "API Keys" } + +export default async function ApiKeysSettingsPage() { + const user = await getSessionUser() + if (!user) redirect("/login") + + const rows = await db + .select({ + id: api_keys.id, + name: api_keys.name, + key_prefix: api_keys.key_prefix, + created_at: api_keys.created_at, + last_used_at: api_keys.last_used_at, + revoked_at: api_keys.revoked_at, + }) + .from(api_keys) + .where(eq(api_keys.user_id, user.id)) + .orderBy(desc(api_keys.created_at)) + + const keys: ApiKeyRow[] = rows + + return ( +
+
+

API Keys

+

+ Create keys to authenticate with the public REST API. See the{" "} + + API documentation + {" "} + for available endpoints. +

+
+ +
+ ) +} diff --git a/app/(dashboard)/settings/billing/page.tsx b/app/(dashboard)/settings/billing/page.tsx index 922ad12..a793ae8 100644 --- a/app/(dashboard)/settings/billing/page.tsx +++ b/app/(dashboard)/settings/billing/page.tsx @@ -5,7 +5,9 @@ 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 { getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans" +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" @@ -57,7 +59,7 @@ const PLANS = [ export default async function BillingPage({ searchParams, }: { - searchParams: Promise<{ success?: string; canceled?: string }> + searchParams: Promise<{ success?: string; canceled?: string; error?: string }> }) { const user = await getSessionUser() if (!user) redirect("/login") @@ -70,13 +72,18 @@ 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() const [[{ count: propertiesUsed }], [{ count: tenantsUsed }]] = await Promise.all([ db @@ -106,6 +113,11 @@ export default async function BillingPage({ Checkout canceled — no charge was made.
)} + {params.error === "paypal" && ( +
+ We couldn't complete your PayPal payment. No charge was made — please try again. +
+ )} {/* Current plan */}
@@ -117,9 +129,12 @@ export default async function BillingPage({

Status: {profile.subscription_status}

)}
- {hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && ( - - )} + {currentPlan !== "starter" && currentPlan !== "lifetime" && + (isPaypal ? ( + + ) : hasStripeAccount ? ( + + ) : null)}
@@ -177,7 +192,13 @@ export default async function BillingPage({ {plan.key === "starter" ? "Free" : "Downgrade via portal"}
) : ( - + )}
diff --git a/app/(dashboard)/settings/branding/page.tsx b/app/(dashboard)/settings/branding/page.tsx new file mode 100644 index 0000000..a5be01c --- /dev/null +++ b/app/(dashboard)/settings/branding/page.tsx @@ -0,0 +1,69 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { profiles } from "@/lib/db/schema" +import { getSessionUser } from "@/lib/session" +import { PLAN_LIMITS } from "@/lib/stripe/plans" +import { BrandingForm } from "@/components/forms/branding-form" +import { Sparkles, ArrowRight } from "lucide-react" +import type { Plan } from "@/types" + +export const metadata = { title: "White-Label Branding" } + +export default async function BrandingSettingsPage() { + const user = await getSessionUser() + if (!user) redirect("/login") + + const profile = await db.query.profiles.findFirst({ + where: eq(profiles.id, user.id), + columns: { + plan: true, + brand_name: true, + brand_logo_url: true, + brand_color: true, + hide_powered_by: true, + }, + }) + + const plan = (profile?.plan ?? "starter") as Plan + const hasWhiteLabel = PLAN_LIMITS[plan]?.hasWhiteLabel === true + + return ( +
+
+

White-Label Branding

+

+ Customize how the tenant portal looks with your own brand. +

+
+ + {hasWhiteLabel ? ( + + ) : ( +
+
+ +
+

White-label is a Landlord feature

+

+ Put your own brand name, logo, and accent color on the tenant portal — and remove the + “Powered by” line. Available on the Landlord and Lifetime plans. +

+ + Upgrade to unlock + + +
+ )} +
+ ) +} diff --git a/app/(dashboard)/settings/demo/page.tsx b/app/(dashboard)/settings/demo/page.tsx index 3bffd15..b4d74ea 100644 --- a/app/(dashboard)/settings/demo/page.tsx +++ b/app/(dashboard)/settings/demo/page.tsx @@ -2,7 +2,7 @@ import { seedDemoData, clearDemoData, setTestPlan } from "@/app/actions/seed-dem import { eq } from "drizzle-orm" import { db } from "@/lib/db" import { profiles } from "@/lib/db/schema" -import { getSessionUser } from "@/lib/session" +import { getSessionUser, isAdminUser } from "@/lib/session" import { redirect, notFound } from "next/navigation" import { Building2, Users, CreditCard, Wrench, @@ -30,7 +30,8 @@ export default async function DemoDataPage() { const user = await getSessionUser() if (!user) redirect("/login") - if (process.env.NODE_ENV === "production") notFound() + // Admin-only testing tool — regular users get a 404 (and never see the nav link). + if (!isAdminUser(user)) notFound() const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id), diff --git a/app/(dashboard)/settings/integrations/page.tsx b/app/(dashboard)/settings/integrations/page.tsx new file mode 100644 index 0000000..8cbc225 --- /dev/null +++ b/app/(dashboard)/settings/integrations/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation" +import { getSessionUser } from "@/lib/session" +import { getAccountContext } from "@/lib/account" +import { listProviders, listConnections } from "@/lib/accounting" +import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations" + +export const metadata = { title: "Integrations" } +export const dynamic = "force-dynamic" + +export default async function IntegrationsPage({ + searchParams, +}: { + searchParams: Promise<{ connected?: string; error?: string }> +}) { + const user = await getSessionUser() + if (!user) redirect("/login") + const ctx = await getAccountContext(user.id) + const sp = await searchParams + + const providers = listProviders() + const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : [] + + return ( +
+
+

Integrations

+

+ Connect your accounting software to automatically push rent income and expenses into your books. +

+
+ +
+ ) +} diff --git a/app/(dashboard)/settings/team/page.tsx b/app/(dashboard)/settings/team/page.tsx new file mode 100644 index 0000000..8a53cc0 --- /dev/null +++ b/app/(dashboard)/settings/team/page.tsx @@ -0,0 +1,120 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { and, desc, eq, ne } from "drizzle-orm" +import { db } from "@/lib/db" +import { account_members, profiles } from "@/lib/db/schema" +import { getSessionUser } from "@/lib/session" +import { getAccountContext } from "@/lib/account" +import { PLAN_LIMITS } from "@/lib/stripe/plans" +import { TeamManager, type TeamMember } from "@/components/dashboard/team-manager" +import { Users } from "lucide-react" +import type { Plan } from "@/types" + +export const metadata = { title: "Team Access" } + +export default async function TeamSettingsPage() { + const user = await getSessionUser() + if (!user) redirect("/login") + + const ctx = await getAccountContext(user.id) + + // If the user is a MEMBER of someone else's account, show a read-only note + // instead of management UI — they can't manage the owner's team. + if (!ctx.isOwner) { + const owner = await db.query.profiles.findFirst({ + where: eq(profiles.id, ctx.ownerId), + columns: { full_name: true, company_name: true, email: true }, + }) + const ownerName = + owner?.company_name || owner?.full_name || owner?.email || "another landlord" + + return ( +
+ +
+
+
+ +
+
+

+ You're a {ctx.role} of {ownerName}'s account +

+

+ You're working inside {ownerName}'s portfolio.{" "} + {ctx.canWrite + ? "You can view and edit their data." + : "You have read-only access to their data."}{" "} + Only the account owner can manage team members. +

+
+
+
+
+ ) + } + + // Owner: gate on their plan. + const profile = await db.query.profiles.findFirst({ + where: eq(profiles.id, user.id), + columns: { plan: true }, + }) + const plan = (profile?.plan ?? "starter") as Plan + const hasTeamAccess = PLAN_LIMITS[plan].hasTeamAccess + + if (!hasTeamAccess) { + return ( +
+ +
+
+ +
+

Team access is a paid feature

+

+ Invite staff or co-managers to access your portfolio with the Landlord + or Lifetime plan. Members can help manage your properties, and viewers + get read-only access. +

+ + Upgrade your plan + +
+
+ ) + } + + const rows = await db + .select({ + id: account_members.id, + email: account_members.email, + role: account_members.role, + status: account_members.status, + }) + .from(account_members) + .where(and(eq(account_members.owner_id, user.id), ne(account_members.status, "revoked"))) + .orderBy(desc(account_members.created_at)) + + const members: TeamMember[] = rows + + return ( +
+ + +
+ ) +} + +function PageHeader() { + return ( +
+

Team Access

+

+ Invite people to help manage your property portfolio +

+
+ ) +} diff --git a/app/(dashboard)/settings/webhooks/page.tsx b/app/(dashboard)/settings/webhooks/page.tsx new file mode 100644 index 0000000..4e001dd --- /dev/null +++ b/app/(dashboard)/settings/webhooks/page.tsx @@ -0,0 +1,61 @@ +import { redirect } from "next/navigation" +import Link from "next/link" +import { desc, eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { webhook_endpoints } from "@/lib/db/schema" +import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" +import { WebhookManager } from "@/components/dashboard/webhook-manager" +import type { WebhookEndpointDTO } from "@/app/actions/webhooks" + +export const metadata = { title: "Webhooks" } + +export default async function WebhooksSettingsPage() { + const user = await getSessionUser() + if (!user) redirect("/login") + + // Endpoints belong to the account owner (team-aware) so every portfolio event + // is delivered regardless of which member triggered it. + const ownerId = await getEffectiveOwnerId(user.id) + + const rows = await db + .select() + .from(webhook_endpoints) + .where(eq(webhook_endpoints.user_id, ownerId)) + .orderBy(desc(webhook_endpoints.created_at)) + + const endpoints: WebhookEndpointDTO[] = rows.map((row) => ({ + id: row.id, + url: row.url, + description: row.description, + events: row.events, + secret: row.secret, + status: row.status, + source: row.source, + last_success_at: row.last_success_at, + last_error_at: row.last_error_at, + last_error: row.last_error, + failure_count: row.failure_count, + created_at: row.created_at, + })) + + return ( +
+
+

Webhooks

+

+ Send real-time events to Zapier, Make, or your own server. Each delivery is signed with the + endpoint's secret so you can verify it's from us. See the{" "} + + webhook documentation + {" "} + for the payload format and signature scheme. +

+
+ +
+ ) +} diff --git a/app/(dashboard)/tenants/[tenantId]/edit/page.tsx b/app/(dashboard)/tenants/[tenantId]/edit/page.tsx index 68bcc03..5d4f1d1 100644 --- a/app/(dashboard)/tenants/[tenantId]/edit/page.tsx +++ b/app/(dashboard)/tenants/[tenantId]/edit/page.tsx @@ -3,6 +3,7 @@ import { and, asc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { tenants as tenantsTable, properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { TenantForm } from "@/components/forms/tenant-form" import { BackButton } from "@/components/ui/back-button" @@ -12,14 +13,16 @@ export default async function EditTenantPage({ params }: { params: Promise<{ ten const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const { tenantId } = await params const [tenant, properties_] = await Promise.all([ db.query.tenants.findFirst({ - where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, user.id)), + where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, ownerId)), }), db.query.properties.findMany({ - where: eq(properties.user_id, user.id), + where: eq(properties.user_id, ownerId), columns: { id: true, name: true }, with: { units: { columns: { id: true, unit_number: true, status: true } }, diff --git a/app/(dashboard)/tenants/[tenantId]/page.tsx b/app/(dashboard)/tenants/[tenantId]/page.tsx index 2ae52fa..d670e36 100644 --- a/app/(dashboard)/tenants/[tenantId]/page.tsx +++ b/app/(dashboard)/tenants/[tenantId]/page.tsx @@ -3,6 +3,7 @@ import { and, eq, desc } from "drizzle-orm" import { db } from "@/lib/db" import { tenants as tenantsTable, rent_payments, maintenance_requests, leases as leasesTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import Link from "next/link" import { formatCurrency, formatDate } from "@/lib/utils" import { RentStatusBadge } from "@/components/dashboard/rent-status-badge" @@ -10,15 +11,18 @@ import { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/ma import { Mail, Phone } from "lucide-react" import { CopyButton } from "@/components/shared/copy-button" import { SendReminderButton } from "@/components/shared/send-reminder-button" +import { DeleteTenantButton } from "@/components/forms/delete-tenant-button" export default async function TenantDetailPage({ params }: { params: Promise<{ tenantId: string }> }) { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const { tenantId } = await params const tenant = await db.query.tenants.findFirst({ - where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, user.id)), + where: and(eq(tenantsTable.id, tenantId), eq(tenantsTable.user_id, ownerId)), with: { unit: { columns: { unit_number: true, rent_amount: true, bedrooms: true, bathrooms: true } }, property: { columns: { name: true, address_line1: true, city: true, state: true } }, @@ -31,19 +35,19 @@ export default async function TenantDetailPage({ params }: { params: Promise<{ t db .select() .from(rent_payments) - .where(and(eq(rent_payments.user_id, user.id), eq(rent_payments.tenant_id, tenantId))) + .where(and(eq(rent_payments.user_id, ownerId), eq(rent_payments.tenant_id, tenantId))) .orderBy(desc(rent_payments.due_date)) .limit(6), db .select() .from(maintenance_requests) - .where(and(eq(maintenance_requests.user_id, user.id), eq(maintenance_requests.tenant_id, tenantId))) + .where(and(eq(maintenance_requests.user_id, ownerId), eq(maintenance_requests.tenant_id, tenantId))) .orderBy(desc(maintenance_requests.created_at)) .limit(5), db .select() .from(leasesTable) - .where(and(eq(leasesTable.user_id, user.id), eq(leasesTable.tenant_id, tenantId))) + .where(and(eq(leasesTable.user_id, ownerId), eq(leasesTable.tenant_id, tenantId))) .orderBy(desc(leasesTable.created_at)) .limit(1), ]) @@ -74,12 +78,18 @@ export default async function TenantDetailPage({ params }: { params: Promise<{ t - - Edit - +
+ + Edit + + +
diff --git a/app/(dashboard)/tenants/new/page.tsx b/app/(dashboard)/tenants/new/page.tsx index cabe57a..0d60b55 100644 --- a/app/(dashboard)/tenants/new/page.tsx +++ b/app/(dashboard)/tenants/new/page.tsx @@ -3,6 +3,7 @@ import { asc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { TenantForm } from "@/components/forms/tenant-form" import { BackButton } from "@/components/ui/back-button" @@ -12,8 +13,10 @@ export default async function NewTenantPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const properties_ = await db.query.properties.findMany({ - where: eq(properties.user_id, user.id), + where: eq(properties.user_id, ownerId), columns: { id: true, name: true }, with: { units: { columns: { id: true, unit_number: true, status: true } }, diff --git a/app/(dashboard)/tenants/page.tsx b/app/(dashboard)/tenants/page.tsx index 4f614a6..ff3cdea 100644 --- a/app/(dashboard)/tenants/page.tsx +++ b/app/(dashboard)/tenants/page.tsx @@ -3,6 +3,7 @@ import { and, eq, desc } from "drizzle-orm" import { db } from "@/lib/db" import { tenants as tenantsTable } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { Users, Plus } from "lucide-react" import { EmptyState } from "@/components/shared/empty-state" import Link from "next/link" @@ -15,8 +16,10 @@ export default async function TenantsPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const tenants = await db.query.tenants.findMany({ - where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")), + where: and(eq(tenantsTable.user_id, ownerId), eq(tenantsTable.status, "active")), with: { unit: { columns: { unit_number: true, rent_amount: true } }, property: { columns: { name: true } }, diff --git a/app/(dashboard)/tenants/tenants-table.tsx b/app/(dashboard)/tenants/tenants-table.tsx index 12ffed3..2816c2c 100644 --- a/app/(dashboard)/tenants/tenants-table.tsx +++ b/app/(dashboard)/tenants/tenants-table.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import Link from "next/link" import { Mail, Search, ArrowRight, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react" import { formatDate, formatCurrency } from "@/lib/utils" +import { DeleteTenantButton } from "@/components/forms/delete-tenant-button" type SortKey = "name" | "property" | "move_in" | "rent" type SortDir = "asc" | "desc" @@ -121,12 +122,20 @@ export function TenantsTable({ tenants }: { tenants: any[] }) {

- - View - +
+ + View + + +
))} diff --git a/app/(dashboard)/vendors/page.tsx b/app/(dashboard)/vendors/page.tsx index 5f6a74d..d124e12 100644 --- a/app/(dashboard)/vendors/page.tsx +++ b/app/(dashboard)/vendors/page.tsx @@ -3,6 +3,7 @@ import { asc, eq } from "drizzle-orm" import { db } from "@/lib/db" import { vendors, properties } from "@/lib/db/schema" import { getSessionUser } from "@/lib/session" +import { getEffectiveOwnerId } from "@/lib/account" import { VendorManager } from "./vendor-manager" export const metadata = { title: "Vendors" } @@ -11,16 +12,18 @@ export default async function VendorsPage() { const user = await getSessionUser() if (!user) redirect("/login") + const ownerId = await getEffectiveOwnerId(user.id) + const [vendorList, propertyList] = await Promise.all([ db .select() .from(vendors) - .where(eq(vendors.user_id, user.id)) + .where(eq(vendors.user_id, ownerId)) .orderBy(asc(vendors.name)), db .select({ id: properties.id, name: properties.name }) .from(properties) - .where(eq(properties.user_id, user.id)), + .where(eq(properties.user_id, ownerId)), ]) return ( diff --git a/app/(marketing)/acceptable-use/page.tsx b/app/(marketing)/acceptable-use/page.tsx new file mode 100644 index 0000000..3da5989 --- /dev/null +++ b/app/(marketing)/acceptable-use/page.tsx @@ -0,0 +1,101 @@ +import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" +import { LEGAL } from "@/lib/legal" + +export const metadata = { + title: "Acceptable Use Policy", + description: `The rules that govern acceptable use of the ${LEGAL.service} platform and the activities that are prohibited.`, + alternates: { canonical: "/acceptable-use" }, +} + +export default function Page() { + return ( + +
+

+ This Acceptable Use Policy (the Policy) applies to all access to and use of the{" "} + {LEGAL.service} platform (the Service) operated by {LEGAL.entity} (we,{" "} + us, or our). It applies to you as the account holder (a landlord or + property manager), whom we refer to as you or Customer, and to anyone who + accesses the Service through your account. +

+

+ This Policy forms part of, and is incorporated by reference into, our{" "} + Terms of Service. Capitalized terms that are not defined here have the meaning given + to them in the Terms. Violating this Policy is a violation of the Terms and may result in enforcement action + as described below. +

+
+ +
+

When using the Service, you must not, and must not permit any third party to:

+
    +
  • Use the Service for any illegal, fraudulent, or unauthorized purpose, or in any way that violates applicable law or regulation.
  • +
  • Violate any housing, fair-housing, anti-discrimination, or landlord-tenant laws, including using the Service to discriminate against any Tenant or applicant on a protected basis.
  • +
  • Harass, threaten, defame, or otherwise engage in abusive or unlawful contact with any Tenant or other individual through the Service.
  • +
  • Upload, store, or transmit content that is infringing, unlawful, defamatory, obscene, or that contains viruses, malware, or other malicious code.
  • +
  • Gain or attempt to gain unauthorized access to the Service, to other accounts, or to any systems or networks connected to the Service, including through scraping or automated bulk access.
  • +
  • Probe, scan, or test the vulnerability of the Service, or circumvent, disable, or otherwise interfere with any security features or access controls.
  • +
  • Reverse engineer, decompile, or disassemble any part of the Service, or attempt to derive its source code, except to the extent this restriction is prohibited by applicable law.
  • +
  • Interfere with, disrupt, or place an unreasonable load on the Service or its infrastructure, or attempt to overload or degrade it.
  • +
  • Use the email or notification features of the Service to send spam, unsolicited bulk messages, or any unlawful, deceptive, or harassing communications.
  • +
  • Misuse Tenant personal data, including using it for any purpose other than the lawful management of the relevant tenancy or in a manner inconsistent with your obligations to that Tenant.
  • +
+
+ +
+

+ You may only upload or process Tenant personal data, or any other personal data, for which + you have a lawful basis and, where required, the necessary consent. You must not upload special-category or + sensitive personal data unless doing so is lawful and appropriate for your purposes. +

+

+ For personal data you process through the Service, you act as the data controller and are + responsible for complying with applicable data-protection laws. Our respective obligations are set out in + our Data Processing Addendum, and our data practices are described in our{" "} + Privacy Policy. +

+
+ +
+

+ You must not conduct any unauthorized security testing against the Service. If you discover a security + vulnerability or a potential weakness, please report it responsibly to{" "} + {LEGAL.securityEmail} and give us a reasonable opportunity to + investigate and remediate it before disclosing it to others. We appreciate good-faith reports and will not + pursue action against researchers who act responsibly and within the law. +

+
+ +
+

+ You must respect the usage limits, quotas, and rate limits associated with your plan, and you must use the + Service in a manner consistent with fair use. You must not abuse, circumvent, or attempt to exceed any API + rate limits, or use automated means to consume a disproportionate share of resources. We may apply + technical or contractual measures to protect the Service and other Customers from excessive or abusive use. +

+
+ +
+

+ We may investigate suspected violations of this Policy and take any action we consider appropriate, + including removing or disabling access to offending content, throttling usage, suspending or terminating + accounts, and cooperating with law-enforcement authorities or other third parties. We may act with or + without notice depending on the severity of the violation and the risk to the Service or others. Suspension + and termination are further described in our Terms of Service. +

+
+ +
+

+ If you become aware of any use of the Service that violates this Policy, please report it to us so that we + can investigate. +

+
+ + +
+ ) +} diff --git a/app/(marketing)/api-docs/page.tsx b/app/(marketing)/api-docs/page.tsx index 91cbb8a..5b3c2fb 100644 --- a/app/(marketing)/api-docs/page.tsx +++ b/app/(marketing)/api-docs/page.tsx @@ -1,20 +1,29 @@ import Link from "next/link" -import { ArrowRight, Code2, Lock, Zap, BookOpen } from "lucide-react" +import { Code2, Lock, Zap, BookOpen, Webhook } from "lucide-react" +import { WEBHOOK_EVENTS } from "@/lib/webhooks/events" export const metadata = { - title: "API Docs — Property Management Network", + title: "API Docs", description: "Property Management Network REST API documentation for developers.", + alternates: { canonical: "/api-docs" }, } +// The real, deployed origin. Falls back to a placeholder only when the env var +// isn't set (e.g. local docs previews). +const BASE_URL = `${process.env.NEXT_PUBLIC_APP_URL ?? "https://your-app-url"}/api/v1` + const ENDPOINTS = [ - { method: "GET", path: "/api/properties", desc: "List all properties for the authenticated landlord" }, - { method: "POST", path: "/api/properties", desc: "Create a new property" }, - { method: "GET", path: "/api/tenants", desc: "List all tenants with lease status" }, - { method: "GET", path: "/api/payments", desc: "List rent payments with filters (status, date range)" }, - { method: "POST", path: "/api/payments", desc: "Record a new payment manually" }, - { method: "GET", path: "/api/maintenance", desc: "List all maintenance requests" }, - { method: "POST", path: "/api/maintenance", desc: "Create a maintenance request" }, - { method: "PATCH", path: "/api/maintenance/:id", desc: "Update request status or assign to contractor" }, + { method: "GET", path: "/properties", desc: "List all properties for the authenticated account" }, + { method: "POST", path: "/properties", desc: "Create a new property" }, + { method: "GET", path: "/tenants", desc: "List tenants with their unit and lease status" }, + { method: "GET", path: "/payments", desc: "List rent payments (filter by status, tenant_id, from/to date range)" }, + { method: "POST", path: "/payments", desc: "Record a rent payment" }, + { method: "GET", path: "/maintenance", desc: "List maintenance requests (filter by status, priority, property_id)" }, + { method: "POST", path: "/maintenance", desc: "Create a maintenance request" }, + { method: "PATCH", path: "/maintenance/:id", desc: "Update a maintenance request's status or fields" }, + { method: "GET", path: "/webhooks", desc: "List webhook subscriptions" }, + { method: "POST", path: "/webhooks", desc: "Create a webhook subscription (Zapier REST Hook subscribe)" }, + { method: "DELETE", path: "/webhooks/:id", desc: "Delete a webhook subscription (Zapier REST Hook unsubscribe)" }, ] const METHOD_COLORS: Record = { @@ -41,7 +50,7 @@ export default function ApiDocsPage() {

Base URL: - https://api.propertymanagement.network/v1 + {BASE_URL}
@@ -54,13 +63,15 @@ export default function ApiDocsPage() {

- All API requests require a Bearer token in the Authorization header. Generate your API key from - the dashboard settings. + All API requests require a Bearer API key in the Authorization header. Keys look like{" "} + pmn_live_… and are generated from{" "} + Settings → API keys{" "} + inside your dashboard. The plaintext key is shown only once at creation, so store it securely.

# Example request

-

curl https://api.propertymanagement.network/v1/properties \

-

-H "Authorization: Bearer YOUR_API_KEY"

+

curl {BASE_URL}/properties \

+

-H "Authorization: Bearer pmn_live_..."

@@ -91,27 +102,98 @@ export default function ApiDocsPage() { Response Format
-

All responses are JSON. Successful responses return a data field. Errors return an error field with a message and code.

+

+ All responses are JSON. List endpoints return a data array + with a count. Single-record and create responses return a{" "} + data object (create returns HTTP 201). Errors return an{" "} + error object with a numeric code and a message. +

-

// Success

+

{"// Success (list)"}

{"{"} "data": [...], "count": 12 {"}"}


-

// Error

+

{"// Success (single / create)"}

+

{"{"} "data": {"{"} ... {"}"} {"}"}

+
+

{"// Error"}

{"{"} "error": {"{"} "code": 401, "message": "Unauthorized" {"}"} {"}"}

- {/* Coming soon banner */} -
-

Full SDK coming soon

-

We're building official JavaScript and Python SDKs. Join the waitlist to be notified.

- - Join waitlist - + {/* Webhooks */} +
+

+ Webhooks +

+
+
+

+ Subscribe to real-time events instead of polling. Add endpoints in{" "} + Settings → Webhooks{" "} + (or via the /webhooks API), and we'll POST a + signed JSON payload the moment something happens. This is the same mechanism that powers our{" "} + Zapier integration — Zapier subscribes and unsubscribes + through the POST /webhooks and{" "} + DELETE /webhooks/:id endpoints (the REST Hook pattern). +

+
+ + {/* Events */} +
+
+

Available events

+
+ {WEBHOOK_EVENTS.map((ev, i) => ( +
+ + {ev.id} + +

{ev.description}

+
+ ))} +
+ + {/* Payload + signature */} +
+

Payload & signature

+

+ Each request body is a JSON envelope. Every delivery carries an{" "} + X-PMN-Signature header —{" "} + t=<unix>,v1=<hex> — where{" "} + v1 is the HMAC-SHA256 of{" "} + {"`${t}.${rawBody}`"} keyed with your endpoint's + signing secret. Recompute it and compare in constant time; reject if the timestamp is stale. +

+
+

{"// POST body"}

+

{"{"}

+

"id": "evt_9f2c…",

+

"event": "tenant.created",

+

"created_at": "2026-07-02T12:00:00.000Z",

+

"data": {"{"} "tenant": {"{"} … {"}"} {"}"}

+

{"}"}

+

{"# Headers"}

+

X-PMN-Event: tenant.created

+

X-PMN-Delivery: <delivery id>

+

X-PMN-Signature: t=1751457600,v1=1a2b3c…

+
+

+ Respond with any 2xx to acknowledge. Non-2xx or timeouts are retried + with exponential backoff (up to 5 attempts); a test event is available from the dashboard. +

+
+
+
+ + {/* SDK note */} +
+

+ Official client SDKs are not yet available. Call the endpoints directly over HTTP with any language or HTTP client. +

diff --git a/app/(marketing)/cookie-policy/page.tsx b/app/(marketing)/cookie-policy/page.tsx index 6e318ef..bbda96b 100644 --- a/app/(marketing)/cookie-policy/page.tsx +++ b/app/(marketing)/cookie-policy/page.tsx @@ -1,77 +1,129 @@ +import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" +import { LEGAL } from "@/lib/legal" + export const metadata = { - title: "Cookie Policy — Property Management Network", - description: "How Property Management Network uses cookies and similar tracking technologies.", + title: "Cookie Policy", + description: + "How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.", + alternates: { canonical: "/cookie-policy" }, } -const SECTIONS = [ +const COOKIE_ROWS: { name: string; type: string; purpose: string; expires: string }[] = [ { - title: "What are cookies?", - body: "Cookies are small text files stored on your device by your browser when you visit a website. They help websites remember your preferences, keep you logged in, and understand how the site is being used.", + name: "session", + type: "Strictly necessary", + purpose: "Keeps you signed in and maintains your authenticated session", + expires: "On sign-out or after inactivity", }, { - title: "Cookies we use", - body: "We use the following types of cookies: (1) Essential cookies — required for the application to function, such as session authentication tokens. Without these, you cannot log in. (2) Analytics cookies — we use Vercel Analytics (privacy-preserving, no personal data stored) to understand page performance. (3) Preference cookies — we store your dashboard preferences (dark/light mode, column visibility) in browser localStorage.", + name: "preferences (localStorage)", + type: "Preference", + purpose: "Stores interface preferences such as layout and theme in your browser", + expires: "Persistent until cleared", }, { - title: "Third-party cookies", - body: "Stripe may set cookies when you make a payment for fraud prevention and PCI compliance purposes. We do not use advertising, retargeting, or social media tracking cookies.", - }, - { - title: "How to control cookies", - body: "You can manage cookies through your browser settings. Most browsers allow you to block or delete cookies. Note that blocking essential cookies will prevent you from logging in to Property Management Network. For analytics cookies, you can opt out by enabling the Do Not Track header in your browser.", - }, - { - title: "Cookie retention", - body: "Session cookies expire when you close your browser. Authentication tokens are refreshed automatically and expire after 7 days of inactivity. Analytics data is retained for 90 days in aggregate, with no individual identifiers stored.", - }, - { - title: "Changes to this policy", - body: "We may update this Cookie Policy as we add new features. Significant changes will be announced via the in-app notification banner. The date at the bottom of this page reflects the most recent update.", - }, - { - title: "Contact", - body: "For questions about cookies or this policy, contact us at privacy@propertymanagement.network.", + name: "__stripe_*", + type: "Strictly necessary (Stripe)", + purpose: "Set by Stripe to prevent payment fraud during checkout", + expires: "Session or up to one year", }, ] -export default function CookiePolicyPage() { +export default function Page() { return ( -
-
-
-

Cookie Policy

-

Last updated: April 2026

-
+ +
+

+ Cookies are small text files that a website stores on your device through your browser. + Similar technologies, such as browser localStorage, allow a site to store + data locally in a comparable way. These technologies help a website keep you signed in, + remember your preferences, and operate securely. This policy describes how{" "} + {LEGAL.entity} uses them within {LEGAL.service} (the{" "} + Service). +

+
-
- {SECTIONS.map((s) => ( -
-

{s.title}

-

{s.body}

-
- ))} -
+
+

We use only the following limited set of technologies:

+
    +
  • + Strictly necessary authentication and session cookies—required to + sign you in and to maintain your secure session. The Service cannot function without + these. +
  • +
  • + Preference storage in browser localStorage—used to remember + interface preferences, such as layout and theme, on your device. +
  • +
  • + Stripe cookies—set by Stripe during payment to help prevent fraud + and to support secure checkout. +
  • +
  • + Cloudflare Turnstile challenge cookie—a challenge cookie that may + be set on authentication pages to distinguish genuine users from automated bots. +
  • +
+
- {/* Cookie types summary table */} -
-
-

Cookie Summary

+
+

+ We do not use advertising, retargeting, or cross-site tracking cookies, and + we do not use third-party analytics cookies. We do not build advertising + profiles or share cookie data with advertising networks. +

+
+ +
+

+ Most browsers allow you to view, block, or delete cookies through their settings. Because + our authentication and session cookies are strictly necessary, blocking + them will prevent you from signing in to and using the Service. You can adjust your browser + settings at any time to control non-essential storage. +

+
+ +
+

+ Session cookies are temporary and are cleared when your session ends, while{" "} + persistent storage remains on your device until it expires or you remove + it. Authentication sessions expire after a period of inactivity, after which you will be + asked to sign in again. +

+
+ +
+

+ We may update this Cookie Policy as the Service evolves. When we make material changes, we + will update the date shown above. Please review this page periodically to stay informed. +

+
+ + + +
+
+

+ Cookie summary +

+
+ {COOKIE_ROWS.map((row, i, arr) => ( +
+ {row.name} + {row.type} + {row.purpose} + {row.expires}
- {[ - { name: "pf_session", type: "Essential", purpose: "Authentication session token", expires: "7 days" }, - { name: "pf_prefs", type: "Preference", purpose: "Dashboard layout preferences", expires: "1 year" }, - { name: "_vercel_*", type: "Analytics", purpose: "Anonymous page performance data", expires: "90 days" }, - { name: "__stripe_*", type: "Third-party", purpose: "Stripe payment fraud prevention", expires: "Session" }, - ].map((row, i, arr) => ( -
- {row.name} - {row.type} - {row.purpose} - {row.expires} -
- ))} -
+ ))}
-
+
) } diff --git a/app/(marketing)/disclaimer/page.tsx b/app/(marketing)/disclaimer/page.tsx new file mode 100644 index 0000000..5dc606f --- /dev/null +++ b/app/(marketing)/disclaimer/page.tsx @@ -0,0 +1,94 @@ +import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" +import { LEGAL } from "@/lib/legal" + +export const metadata = { + title: "Disclaimer", + description: + "Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.", + alternates: { canonical: "/disclaimer" }, +} + +export default function Page() { + return ( + + + {LEGAL.service} (the Service) and all of its outputs are + provided for general informational purposes only and do not constitute + professional advice. You should not rely on them as a substitute for advice from a qualified + professional. + + +
+

+ The information made available through the Service is provided by{" "} + {LEGAL.entity} (we, us, or{" "} + our) for general informational purposes. While we aim to keep the Service + useful and reliable, we make no representations or warranties as to the accuracy, + completeness, or timeliness of any information or output. +

+
+ +
+

+ Nothing provided through the Service constitutes legal,{" "} + tax, financial, accounting, or{" "} + real-estate advice. You (the account holder, referred to as{" "} + you or the Customer) should consult qualified + professionals before making any decision based on the Service. +

+
+ +
+

+ Some features generate content using artificial intelligence. AI-generated content may be{" "} + inaccurate, incomplete, or outdated, and may not reflect your specific + circumstances. You must independently verify any AI-generated content before relying on it. + The Customer is solely responsible for any decision made using such + content. +

+
+ +
+

+ Any financial figures, summaries, and reports produced by the Service are provided for + convenience only. You should verify them against your own official records. These outputs + are not a substitute for professional accounting, bookkeeping, or audit + services. +

+
+ +
+

+ The Customer is solely responsible for complying with all applicable laws + and regulations, including housing, fair-housing,{" "} + landlord-tenant, tax, and{" "} + data-protection laws. The Service is a tool to assist you and does not + ensure or guarantee your compliance with any legal obligation. +

+
+ +
+

+ The Service may include content from, or links to, third-party websites and services. We do + not control and are not responsible for the content, accuracy, or practices of any third + party. The inclusion of any link or third-party content does not imply endorsement. +

+
+ +
+

+ The Service is provided on an “as is” and{" "} + “as available” basis, without warranties of any kind, whether + express or implied, to the fullest extent permitted by law. This page is a summary only. The + full warranty disclaimer and the limitation of liability, including any liability cap, are + set out in our Terms of Service, which govern your use of the Service. +

+
+ + +
+ ) +} diff --git a/app/(marketing)/dpa/page.tsx b/app/(marketing)/dpa/page.tsx new file mode 100644 index 0000000..79dec27 --- /dev/null +++ b/app/(marketing)/dpa/page.tsx @@ -0,0 +1,247 @@ +import Link from "next/link" +import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" +import { LEGAL } from "@/lib/legal" + +export const metadata = { + title: "Data Processing Addendum", + description: `The Data Processing Addendum governing how ${LEGAL.entity} processes personal data on behalf of Customers using ${LEGAL.service}.`, + alternates: { canonical: "/dpa" }, +} + +export default function DpaPage() { + return ( + + + This DPA forms part of the{" "} + Terms of Service between you and {LEGAL.entity} and + applies wherever we process personal data on the Customer’s behalf (for + example, Tenant personal data managed through the Service). Where there is a + conflict between this DPA and the Terms in respect of the processing of personal + data, this DPA prevails. + + +
+

+ In this DPA, Customer (also you) means the + account holder using {LEGAL.service}. We, us, + and our mean {LEGAL.entity}. A Tenant means a + data subject whose personal data the Customer manages through the Service. +

+

+ With respect to Tenant personal data and other personal data that the Customer + submits to the Service, the Customer acts as the data{" "} + controller and we act as the data{" "} + processor, processing that personal data solely on the + Customer’s behalf. With respect to the Customer’s own account data + (for example, the name and contact details of the account holder and billing + information), we act as a controller in our own right, as + described in our{" "} + Privacy Policy. +

+
+ +
+

Unless otherwise defined here, the following terms have the meanings given below:

+
    +
  • + Controller means the entity that determines the purposes and + means of the processing of personal data. +
  • +
  • + Processor means the entity that processes personal data on + behalf of the controller. +
  • +
  • + Personal Data means any information relating to an identified + or identifiable natural person that is processed under this DPA. +
  • +
  • + Data Subject means the identified or identifiable natural + person to whom Personal Data relates. +
  • +
  • + Processing means any operation performed on Personal Data, + whether or not by automated means, including collection, storage, use, and + deletion. +
  • +
  • + Sub-processor means any third party engaged by us to process + Personal Data on behalf of the Customer. +
  • +
  • + Applicable Data Protection Law means all laws and regulations + applicable to the processing of Personal Data under this DPA, including the EU + General Data Protection Regulation (Regulation (EU) 2016/679) (the{" "} + GDPR) and the United Kingdom General Data Protection + Regulation (the UK GDPR). +
  • +
  • + Standard Contractual Clauses means the standard data + protection clauses approved by the European Commission (or the equivalent UK + transfer mechanism) for the transfer of Personal Data to processors + established in third countries. +
  • +
+
+ +
+

+ The subject matter, duration, nature, and purpose of the processing, and the + types of Personal Data and categories of Data Subjects, are as follows: +

+
    +
  • + Subject matter: the provision of the Service to the Customer. +
  • +
  • + Duration: the term of the agreement between the Customer and + us, plus the deletion window described in Section 11. +
  • +
  • + Nature and purpose: hosting, storage, and processing of + Personal Data as necessary to operate the property-management features of the + Service. +
  • +
  • + Types of Personal Data: names, contact details, tenancy + information, lease information, and payment-status data. +
  • +
  • + Categories of Data Subjects: the Customer’s Tenants and + contacts. +
  • +
+
+ +
+

When acting as a processor on the Customer’s behalf, we shall:

+
    +
  • + process Personal Data only on the Customer’s documented instructions, + including with regard to international transfers, unless required to do + otherwise by law (in which case we shall inform the Customer of that legal + requirement before processing, unless prohibited from doing so); +
  • +
  • + ensure that persons authorized to process Personal Data have committed + themselves to confidentiality or are under an appropriate statutory obligation + of confidentiality; +
  • +
  • + implement appropriate technical and organizational measures to ensure a level + of security appropriate to the risk, in accordance with Article 32 of the + GDPR; +
  • +
  • + taking into account the nature of the processing, assist the Customer by + appropriate technical and organizational measures in responding to requests + from Data Subjects seeking to exercise their rights; +
  • +
  • + assist the Customer in ensuring compliance with its obligations relating to + the security of processing, personal-data breach notification, data-protection + impact assessments (DPIAs), and prior consultations with supervisory + authorities; +
  • +
  • + make available to the Customer the information necessary to demonstrate + compliance with the obligations set out in this DPA. +
  • +
+
+ +
+

+ The Customer provides a general authorization for us to engage Sub-processors to + process Personal Data in connection with the Service. Our current Sub-processors + are listed on our{" "} + Sub-processors page. +

+

+ Where we engage a Sub-processor, we impose data-protection obligations that are + substantially equivalent to those set out in this DPA. We give the Customer prior + notice of any intended addition or replacement of a Sub-processor, and the + Customer may object to the change on legitimate data-protection grounds. We remain + responsible for the performance of each Sub-processor’s obligations. +

+
+ +
+

+ Where processing of Personal Data involves a transfer to a country outside the + European Economic Area or the United Kingdom that has not been recognized as + providing an adequate level of protection, we implement an appropriate transfer + mechanism, such as the Standard Contractual Clauses or another lawful mechanism + recognized under Applicable Data Protection Law. +

+
+ +
+

+ Taking into account the nature of the processing, we assist the Customer, as + controller, by appropriate technical and organizational measures, insofar as + this is possible, in fulfilling the Customer’s obligation to respond to + requests from Data Subjects exercising their rights under Applicable Data + Protection Law. Where we receive a request directly from a Data Subject in + respect of Personal Data processed on the Customer’s behalf, we shall, + unless legally required to respond, forward that request to the Customer without + undue delay. +

+
+ +
+

+ We shall notify the Customer without undue delay after becoming aware of a + personal-data breach affecting Personal Data processed on the Customer’s + behalf. That notification shall, to the extent available, describe the nature of + the breach, its likely consequences, and the measures taken or proposed to + address it, so that the Customer can meet its own notification obligations. +

+
+ +
+

+ We make available to the Customer the information necessary to demonstrate + compliance with this DPA and allow for and contribute to audits, including + inspections, conducted by the Customer or an auditor mandated by the Customer. + Audits are subject to reasonable prior written notice, are conducted during + normal business hours in a manner that does not disrupt our operations, and are + subject to appropriate confidentiality obligations. +

+
+ +
+

+ Upon termination or expiry of the agreement, we shall, at the Customer’s + choice, delete or return all Personal Data processed on the Customer’s + behalf, and delete existing copies, within {LEGAL.dataDeletionDays} days, save + where retention of the Personal Data is required by Applicable Data Protection + Law or other law, in which case we shall protect that Personal Data and process + it only as necessary for the purpose that requires its retention. +

+
+ +
+

+ Each party’s liability under or in connection with this DPA is subject to + the exclusions and limitations of liability set out in the{" "} + Terms of Service. +

+
+ +
+

+ This DPA is incorporated into, and forms part of, the Terms of Service and takes + effect upon the Customer’s acceptance of the Terms and use of the Service. + A countersigned copy of this DPA is available on request by contacting{" "} + {LEGAL.dpoEmail}. +

+
+ + +
+ ) +} diff --git a/app/(marketing)/gdpr/page.tsx b/app/(marketing)/gdpr/page.tsx index 4b8e801..5ce7e7e 100644 --- a/app/(marketing)/gdpr/page.tsx +++ b/app/(marketing)/gdpr/page.tsx @@ -1,145 +1,162 @@ import Link from "next/link" +import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" +import { LEGAL } from "@/lib/legal" export const metadata = { - title: "GDPR Compliance — Property Management Network", - description: "Property Management Network's commitment to GDPR compliance and your data rights as a data subject.", + title: "GDPR & Data Rights", + description: `How ${LEGAL.entity} complies with the GDPR and UK GDPR, and the data rights available to you as a data subject.`, + alternates: { canonical: "/gdpr" }, } -const RIGHTS = [ - { right: "Right of access", desc: "You can request a full export of all data we hold about you at any time from your account settings." }, - { right: "Right to rectification", desc: "You can update your personal information directly in your account settings, or contact us to correct inaccurate data." }, - { right: "Right to erasure", desc: "You can permanently delete your account and all associated data from the settings page. Deletion is irreversible and processed within 30 days." }, - { right: "Right to data portability", desc: "You can export your data in machine-readable JSON or CSV format from the dashboard at any time." }, - { right: "Right to restriction", desc: "You may request that we restrict processing of your data while a dispute is being resolved." }, - { right: "Right to object", desc: "You may object to processing where we rely on legitimate interest. You can opt out of analytics tracking by enabling Do Not Track in your browser." }, -] - export default function GdprPage() { return ( -
-
-
-
- EU GDPR Compliant -
-

GDPR Compliance

-

Last updated: April 2026

-
+ +
+

+ {LEGAL.entity} is committed to protecting personal data and to complying with the + General Data Protection Regulation (Regulation (EU) 2016/679) (the{" "} + GDPR) and the United Kingdom General Data Protection Regulation + (the UK GDPR) where applicable. This page explains the rights + available to individuals whose personal data we process and how those rights may + be exercised in connection with {LEGAL.service}. +

+
-
-
-

Who we are

-

- Property Management Network ("we", "us", "our") is the data controller for personal data collected through our platform. - We are committed to complying with the General Data Protection Regulation (EU) 2016/679 (GDPR) - and the UK GDPR where applicable. -

-
+
+

+ Subject to the conditions in Applicable Data Protection Law, you have the + following rights: +

+
    +
  • + Right of access — to obtain confirmation of whether we + process your personal data and to receive a copy of it. +
  • +
  • + Right to rectification — to have inaccurate personal + data corrected and incomplete data completed. +
  • +
  • + Right to erasure — to have your personal data deleted in + certain circumstances. +
  • +
  • + Right to restriction of processing — to limit how we + process your personal data in certain circumstances. +
  • +
  • + Right to data portability — to receive your personal + data in a structured, commonly used, machine-readable format. +
  • +
  • + Right to object — to object to processing that relies on + our legitimate interests. +
  • +
  • + Right to withdraw consent — where processing is based on + consent, to withdraw that consent at any time. +
  • +
  • + Right to lodge a complaint — to lodge a complaint with a + supervisory authority. +
  • +
+
-
-

What data we process

-

We process the following categories of personal data:

-
    - {[ - "Account data: name, email address, password hash", - "Property data: addresses, rental amounts, lease terms you enter", - "Tenant data: names, emails, phone numbers you provide as a landlord", - "Payment data: payment amounts, dates, and status (card details handled by Stripe, not us)", - "Usage data: pages visited, features used — anonymised via Vercel Analytics", - ].map((item) => ( -
  • - - {item} -
  • - ))} -
-
+ -
-

Legal basis for processing

-

- We process personal data on the following legal bases: (1) Contract — data necessary to provide the service you signed up for. - (2) Legitimate interest — anonymous analytics to improve the product. (3) Legal obligation — where required by applicable law. - We do not process data on the basis of consent for core functionality. -

-
+
+

+ Personal data is stored in a managed PostgreSQL database hosted on DigitalOcean, + with an EU region available. Uploaded files are stored privately in DigitalOcean + Spaces. Access isolation is enforced at the application layer: every request is + authenticated and scoped to the relevant account so that data is not accessible + to other users. Where personal data is transferred to a country that has not been + recognized as providing an adequate level of protection, the transfer is protected + by Standard Contractual Clauses or another lawful transfer mechanism. +

+
-
-

Data storage and transfers

-

- Your data is stored in Supabase (PostgreSQL), with servers located in the EU (Frankfurt, Germany) by default. - Row-level security (RLS) policies ensure only you can access your data. We do not transfer personal data outside - the EEA except where strictly necessary for integrated services (e.g. Stripe for payment processing, - which is covered by Standard Contractual Clauses). -

-
+
+

+ We retain personal data for as long as it is needed to provide the Service. Upon + deletion of an account, associated personal data is deleted within{" "} + {LEGAL.dataDeletionDays} days, except where a longer retention period is required + by law (for example, certain financial records that must be kept for tax + purposes). +

+
-
-

Your rights under GDPR

-

As a data subject, you have the following rights:

-
- {RIGHTS.map((r) => ( -
-

{r.right}

-

{r.desc}

-
- ))} -
-
+
+

+ In the event of a personal-data breach, we notify affected users and, where + required, the relevant supervisory authority within 72 hours of becoming aware of + the breach, consistent with Article 33 of the GDPR. +

+
-
-

Data retention

-

- We retain account data for as long as your account is active. Upon deletion, all personal data is purged within 30 days, - except where retention is required by law (e.g. financial records may be retained for up to 7 years for tax compliance). -

-
+
+

+ We use vetted third-party sub-processors to help operate the Service. Our current + sub-processors are listed on our{" "} + Sub-processors page, and the terms governing + their engagement are set out in our{" "} + Data Processing Addendum. +

+
-
-

Data breach notification

-

- In the event of a data breach affecting your personal data, we will notify affected users within 72 hours of becoming aware, - in accordance with GDPR Article 33 obligations. -

-
+
+

+ To exercise any of the rights described above, contact us at{" "} + {LEGAL.privacyEmail}. For + data-protection matters, you may also contact our data-protection team at{" "} + {LEGAL.dpoEmail}. You also have the right + to lodge a complaint with your local supervisory authority (for example, the + Information Commissioner’s Office in the United Kingdom, or your national + data-protection authority in the European Union). +

+
-
-

Sub-processors

-
- {[ - { name: "Supabase", purpose: "Database & file storage", location: "EU (Frankfurt)" }, - { name: "Stripe", purpose: "Payment processing", location: "US (SCCs in place)" }, - { name: "Resend", purpose: "Transactional email", location: "US (SCCs in place)" }, - { name: "Vercel", purpose: "Hosting & edge network", location: "Global (anonymised data only)" }, - ].map((sp, i, arr) => ( -
- {sp.name} - {sp.purpose} - {sp.location} -
- ))} -
-
+
+

+ Where you use the Service to manage the personal data of your Tenants, you act as + the data controller and we act as the data{" "} + processor, processing that personal data on your documented + instructions under our{" "} + Data Processing Addendum. Where we process your own + account data, we act as a controller, as described in our{" "} + Privacy Policy. +

+
-
-

Contact & complaints

-

- To exercise any of your rights or to raise a data protection concern, contact our Data Protection lead at{" "} - - privacy@propertymanagement.network - - . You also have the right to lodge a complaint with your local supervisory authority (e.g. the ICO in the UK, - or your national DPA in the EU). -

-

- See also our{" "} - Privacy Policy{" "} - and{" "} - Cookie Policy. -

-
-
-
-
+ + ) } diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx index 0cc894e..e9ae79b 100644 --- a/app/(marketing)/layout.tsx +++ b/app/(marketing)/layout.tsx @@ -1,9 +1,23 @@ import { Navbar } from "@/components/marketing/navbar" import { Footer } from "@/components/marketing/footer" +import { StructuredData } from "@/components/marketing/structured-data" +import { getSession, isAdminUser } from "@/lib/session" +import { getMaintenanceMode } from "@/lib/settings" +import { MaintenanceScreen } from "@/components/shared/maintenance-screen" + +export default async function MarketingLayout({ children }: { children: React.ReactNode }) { + // Site maintenance mode: show the maintenance screen to everyone except admins. + const maintenance = await getMaintenanceMode() + if (maintenance.enabled) { + const session = await getSession() + if (!isAdminUser(session?.user)) { + return + } + } -export default function MarketingLayout({ children }: { children: React.ReactNode }) { return (
+ {children}