Compare commits

...
2 Commits
Author SHA1 Message Date
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00
Leon SerfatyandClaude Opus 4.8 969d5d4c8a Security hardening from 2026-07-01 audit
- Exclude supabase/ from Docker build context (leaked service_role key file)
- /api/files: exact per-user namespace match + reject path traversal;
  storage resolveKey rejects ".."/"." segments (fixes cross-user file read)
- Add ownsProperty/Unit/Tenant checks to tenants, maintenance (landlord path),
  and documents (JSON branch, now field-whitelisted) create handlers
- Escape user data in follow-up + payment-link emails (reuse escapeHtml)
- Neutralize CSV formula injection in toCsv + export routes
- Tighter sign-in rate limit (10/min); env-gated email verification + sender
- Per-request nonce CSP; drop script-src 'unsafe-inline' (styles unchanged)
- Add input length bounds; validate follow-ups POST body

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:56:34 -04:00
282 changed files with 41714 additions and 4076 deletions
+172
View File
@@ -0,0 +1,172 @@
# ─────────────────────────────────────────────────────────────────────────────
# DigitalOcean App Platform spec — Property Management Network
#
# Deploy: doctl apps create --spec .do/app.yaml
# Update: doctl apps update <APP_ID> --spec .do/app.yaml
#
# SOURCE: image-based from DigitalOcean Container Registry (DOCR). The app's git
# 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
-1
View File
@@ -16,7 +16,6 @@ storage
.git
.gitignore
.gitattributes
.vercel
*.tsbuildinfo
# Editor / OS noise
+88 -10
View File
@@ -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
# <APP_URL>/api/paypal/webhook and set its id as PAYPAL_WEBHOOK_ID. Generate the
# plan IDs once with `node scripts/paypal-setup-plans.mjs` and paste them below.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=sandbox
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) ===
# Get from: https://platform.openai.com/api-keys
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
# <APP_URL>/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 → <APP_URL>/api/esign/docusign/webhook ; Dropbox Sign callback →
# <APP_URL>/api/esign/dropbox_sign/webhook
DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_ACCOUNT_ID=
DOCUSIGN_BASE_URI=https://demo.docusign.net
# === 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=
+99 -15
View File
@@ -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@<service-name>:5432/DB
DATABASE_URL=postgres://user:password@db:5432/pmn
# DO Managed Postgres (private host, direct port 25060): postgres://USER:PASSWORD@<private-host>: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
# <APP_URL>/api/paypal/webhook and put its id in PAYPAL_WEBHOOK_ID. Generate the
# plan IDs with `node scripts/paypal-setup-plans.mjs`.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=live
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) ===
OPENAI_API_KEY=sk-xxx
# === 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: <APP_URL>/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 → <APP_URL>/api/esign/dropbox_sign/webhook
# DocuSign Connect → <APP_URL>/api/esign/docusign/webhook
# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs.
DROPBOX_SIGN_API_KEY=
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=
+1 -8
View File
@@ -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
-127
View File
@@ -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@<service>: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=<strong-password>
POSTGRES_DB=pmn
BETTER_AUTH_SECRET=<hex>
BETTER_AUTH_URL=https://your-domain
NEXT_PUBLIC_APP_URL=https://your-domain
NEXT_PUBLIC_APP_NAME=Property Management Network
CRON_SECRET=<random>
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://<your-domain>/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://<domain>/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 `<BETTER_AUTH_URL>/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`).
+182
View File
@@ -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 <your-registry>` (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=<scoped-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/<your-registry>
docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
-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:<pw>@<public-host>:25060/propertymanagementnetwork" \
DATABASE_SSL=no-verify node scripts/migrate.mjs
```
The migrator is idempotent (only pending migrations run). The current schema (00000002)
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://<your-domain>
CRON_SECRET=<same value as the app's 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://<your-domain>/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://<your-domain>/api/auth/callback/google`
as an authorized redirect URI.
---
## Post-deploy checklist
- [ ] `https://<domain>/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
+5 -3
View File
@@ -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))"
+22 -27
View File
@@ -1,7 +1,7 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/logo-light.svg">
<img alt="Property Management Network" src="public/logo-dark.svg" width="360">
<source media="(prefers-color-scheme: dark)" srcset="public/logo-light.png">
<img alt="Property Management Network" src="public/logo-dark.png" width="360">
</picture>
</p>
@@ -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 <noreply@yourdomain.com>
# 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)
+6 -6
View File
@@ -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 <CRON_SECRET>`.
Configure the same value in your host's env (e.g. DigitalOcean App Platform)
so the scheduled tasks send `Authorization: Bearer <CRON_SECRET>`.
- [ ] **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`
+12 -1
View File
@@ -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() {
</p>
</div>
{/* Maintenance mode control */}
<div className="mb-5">
<MaintenanceToggle
initialEnabled={maintenance.enabled}
initialMessage={maintenance.message ?? ""}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
{/* ── Environment configuration ───────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
+3
View File
@@ -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({
/>
</div>
<TurnstileWidget />
<button
type="submit"
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
+6 -1
View File
@@ -1,15 +1,17 @@
import Link from "next/link"
import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signIn, signInWithGoogle } from "@/app/actions/auth"
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; success?: string }>
searchParams: Promise<{ error?: string; success?: string; next?: string }>
}) {
const params = await searchParams
const error = params.error
const success = params.success
const next = typeof params.next === "string" && params.next.startsWith("/") ? params.next : ""
return (
<div className="w-full">
@@ -54,6 +56,7 @@ export default async function LoginPage({
{/* Email + Password form */}
<form action={signIn} className="space-y-4">
{next && <input type="hidden" name="next" value={next} />}
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-white/70">
Email
@@ -89,6 +92,8 @@ export default async function LoginPage({
/>
</div>
<TurnstileWidget />
<button
type="submit"
className="mt-2 w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
+6 -1
View File
@@ -1,15 +1,17 @@
import Link from "next/link"
import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signUp, signInWithGoogle } from "@/app/actions/auth"
export default async function SignupPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; success?: string }>
searchParams: Promise<{ error?: string; success?: string; next?: string }>
}) {
const params = await searchParams
const error = params.error
const success = params.success
const next = typeof params.next === "string" && params.next.startsWith("/") ? params.next : ""
if (success === "check-email") {
return (
@@ -72,6 +74,7 @@ export default async function SignupPage({
)}
<form action={signUp} className="space-y-4">
{next && <input type="hidden" name="next" value={next} />}
<div>
<label htmlFor="full_name" className="mb-1.5 block text-sm font-medium text-white/70">
Full name
@@ -118,6 +121,8 @@ export default async function SignupPage({
/>
</div>
<TurnstileWidget />
<button
type="submit"
className="mt-2 w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
+4 -1
View File
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { activity_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { ActivityFeed } from "./activity-feed"
export const metadata = { title: "Activity" }
@@ -11,10 +12,12 @@ export default async function ActivityPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const activities = await db
.select()
.from(activity_log)
.where(eq(activity_log.user_id, user.id))
.where(eq(activity_log.user_id, ownerId))
.orderBy(desc(activity_log.created_at))
.limit(100)
+10 -7
View File
@@ -10,6 +10,7 @@ import {
maintenance_requests,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { AiDashboardClient } from "./ai-dashboard-client"
export const metadata = { title: "AI Dashboard" }
@@ -18,6 +19,8 @@ export default async function AiDashboardPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const now = new Date()
const threeMonthsAgo = new Date(now)
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
@@ -26,19 +29,19 @@ export default async function AiDashboardPage() {
db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.where(eq(ai_recommendations.user_id, ownerId))
.orderBy(desc(ai_recommendations.created_at))
.limit(3),
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))
.limit(3),
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(5),
db
@@ -46,20 +49,20 @@ export default async function AiDashboardPage() {
.from(rent_payments)
.where(
and(
eq(rent_payments.user_id, user.id),
eq(rent_payments.user_id, ownerId),
gte(rent_payments.due_date, threeMonthsAgo.toISOString().slice(0, 10))
)
),
db
.select({ status: unitsTable.status })
.from(unitsTable)
.where(eq(unitsTable.user_id, user.id)),
.where(eq(unitsTable.user_id, ownerId)),
db
.select({ status: maintenance_requests.status, priority: maintenance_requests.priority })
.from(maintenance_requests)
.where(
and(
eq(maintenance_requests.user_id, user.id),
eq(maintenance_requests.user_id, ownerId),
inArray(maintenance_requests.status, ["open", "in_progress"])
)
),
@@ -68,7 +71,7 @@ export default async function AiDashboardPage() {
const allRecsData = await db
.select({ status: ai_recommendations.status, action_data: ai_recommendations.action_data })
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.where(eq(ai_recommendations.user_id, ownerId))
const approvedRecs = allRecsData.filter((r) => r.status === "approved")
let totalImpact = 0
+196 -191
View File
@@ -1,222 +1,227 @@
"use client"
import { useState } from "react"
import { ChevronLeft, ChevronRight, CreditCard, FileText } from "lucide-react"
import { cn, formatCurrency } from "@/lib/utils"
import { useEffect, useState } from "react"
import Link from "next/link"
import FullCalendar from "@fullcalendar/react"
import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid"
import listPlugin from "@fullcalendar/list"
import interactionPlugin from "@fullcalendar/interaction"
import type { EventClickArg, EventDropArg } from "@fullcalendar/core"
import { toast } from "sonner"
import { CreditCard, FileText, ClipboardList, X, CalendarDays, Copy, Check, ArrowRight } from "lucide-react"
import "./calendar.css"
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"]
type EventType = "rent" | "lease" | "inspection"
interface CalendarClientProps {
payments: any[]
leases: any[]
export interface CalEvent {
id: string
title: string
start: string
allDay: boolean
backgroundColor: string
borderColor: string
editable: boolean
extendedProps: {
type: EventType
entityId: string
status?: string
subtitle: string
href: string
}
}
export function CalendarClient({ payments, leases }: CalendarClientProps) {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [selected, setSelected] = useState<string | null>(null)
const TYPE_META: Record<EventType, { label: string; color: string; Icon: typeof CreditCard }> = {
rent: { label: "Rent", color: "#6366f1", Icon: CreditCard },
lease: { label: "Leases", color: "#f59e0b", Icon: FileText },
inspection: { label: "Inspections", color: "#14b8a6", Icon: ClipboardList },
}
function prevMonth() {
if (month === 0) { setMonth(11); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear(y => y + 1) }
else setMonth(m => m + 1)
export function CalendarClient({
events,
canWrite,
subscribeUrl,
}: {
events: CalEvent[]
canWrite: boolean
subscribeUrl: string
}) {
const [mounted, setMounted] = useState(false)
const [active, setActive] = useState<Record<EventType, boolean>>({ rent: true, lease: true, inspection: true })
const [selected, setSelected] = useState<(CalEvent["extendedProps"] & { title: string; start: string }) | null>(null)
const [showSubscribe, setShowSubscribe] = useState(false)
useEffect(() => setMounted(true), [])
const shown = events.filter((e) => active[e.extendedProps.type])
function toggle(t: EventType) {
setActive((a) => ({ ...a, [t]: !a[t] }))
}
// Build calendar grid
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const cells: (number | null)[] = [
...Array(firstDay).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
]
// Pad to complete last row
while (cells.length % 7 !== 0) cells.push(null)
function dateKey(day: number) {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
function onEventClick(info: EventClickArg) {
info.jsEvent.preventDefault()
const p = info.event.extendedProps as CalEvent["extendedProps"]
setSelected({ ...p, title: info.event.title, start: info.event.startStr })
}
// Group events by date
const eventsByDate: Record<string, { type: "payment" | "lease"; item: any }[]> = {}
for (const p of payments) {
const key = p.due_date?.slice(0, 10)
if (!key) continue
if (!eventsByDate[key]) eventsByDate[key] = []
eventsByDate[key].push({ type: "payment", item: p })
async function onEventDrop(info: EventDropArg) {
const { type, entityId } = info.event.extendedProps as CalEvent["extendedProps"]
if (!canWrite || type === "lease") {
info.revert()
toast.error(type === "lease" ? "Lease end dates can't be moved here" : "You don't have permission to reschedule")
return
}
const newDate = info.event.startStr.slice(0, 10)
const endpoint = type === "rent" ? `/api/rent/${entityId}` : `/api/inspections/${entityId}`
const body = type === "rent" ? { due_date: newDate } : { date: newDate }
try {
const res = await fetch(endpoint, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
if (res.ok) toast.success(`Rescheduled to ${newDate}`)
else {
info.revert()
toast.error("Couldn't reschedule")
}
} catch {
info.revert()
toast.error("Network error")
}
for (const l of leases) {
const key = l.lease_end?.slice(0, 10)
if (!key) continue
if (!eventsByDate[key]) eventsByDate[key] = []
eventsByDate[key].push({ type: "lease", item: l })
}
const selectedEvents = selected ? (eventsByDate[selected] ?? []) : []
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Calendar grid */}
<div className="lg:col-span-2 rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
{/* Month nav */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
<button onClick={prevMonth} className="rounded-lg p-1.5 text-white/40 hover:text-white transition hover:bg-white/[0.05]">
<ChevronLeft className="h-4 w-4" />
</button>
<h3 className="text-sm font-semibold text-white">{MONTHS[month]} {year}</h3>
<button onClick={nextMonth} className="rounded-lg p-1.5 text-white/40 hover:text-white transition hover:bg-white/[0.05]">
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-white/[0.04]">
{DAYS.map((d) => (
<div key={d} className="py-2 text-center text-[10px] font-semibold uppercase tracking-wider text-white/25">
{d}
</div>
))}
</div>
{/* Days */}
<div className="grid grid-cols-7">
{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")
<div className="space-y-4">
{/* Toolbar: filters + subscribe */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{(Object.keys(TYPE_META) as EventType[]).map((t) => {
const m = TYPE_META[t]
const on = active[t]
return (
<div
key={i}
onClick={() => 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",
)}
<button
key={t}
onClick={() => toggle(t)}
className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition"
style={{
borderColor: on ? m.color + "66" : "rgba(255,255,255,0.08)",
background: on ? m.color + "1a" : "transparent",
color: on ? "#fff" : "rgba(255,255,255,0.4)",
}}
>
{day && (
<>
<span className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium",
isToday ? "bg-indigo-600 text-white font-bold" : "text-white/50"
)}>
{day}
</span>
<div className="mt-1 space-y-0.5">
{paymentEvents.slice(0, 2).map((e, j) => (
<div key={j} className={cn(
"truncate rounded px-1 py-0.5 text-[9px] font-medium",
e.item.status === "paid"
? "bg-emerald-500/15 text-emerald-400"
: e.item.status === "overdue"
? "bg-red-500/15 text-red-400"
: "bg-indigo-500/15 text-indigo-400"
)}>
{e.item.tenant?.first_name} {formatCurrency(e.item.amount)}
</div>
))}
{leaseEvents.slice(0, 1).map((e, j) => (
<div key={j} className="truncate rounded bg-amber-500/15 px-1 py-0.5 text-[9px] font-medium text-amber-400">
Lease ends: {e.item.tenant?.first_name}
</div>
))}
{events.length > 3 && (
<div className="text-[9px] text-white/30 px-1">+{events.length - 3} more</div>
)}
</div>
</>
)}
</div>
<span className="h-2 w-2 rounded-full" style={{ background: m.color, opacity: on ? 1 : 0.4 }} />
{m.label}
</button>
)
})}
</div>
<button
onClick={() => setShowSubscribe(true)}
className="flex items-center gap-2 rounded-lg border border-indigo-500/30 bg-indigo-500/10 px-3 py-1.5 text-xs font-semibold text-indigo-300 transition hover:bg-indigo-500/15"
>
<CalendarDays className="h-3.5 w-3.5" /> Subscribe / Sync
</button>
</div>
{/* Side panel */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="border-b border-white/[0.06] px-5 py-4">
<h3 className="text-sm font-semibold text-white">
{selected ? new Date(selected + "T12:00:00").toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }) : "Select a date"}
</h3>
</div>
{!selected ? (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<p className="text-sm text-white/30">Click any day to see events</p>
</div>
) : selectedEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<p className="text-sm text-white/30">No events this day</p>
</div>
{/* Calendar */}
<div className="fc-dark rounded-2xl border border-white/[0.06] bg-[#16161f] p-3 sm:p-4">
{mounted ? (
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listMonth" }}
buttonText={{ today: "Today", month: "Month", week: "Week", day: "Day", list: "Agenda" }}
events={shown}
editable={canWrite}
eventStartEditable={canWrite}
eventDurationEditable={false}
dayMaxEvents={4}
height="auto"
firstDay={0}
eventClick={onEventClick}
eventDrop={onEventDrop}
noEventsText="Nothing scheduled"
/>
) : (
<div className="divide-y divide-white/[0.04] p-3 space-y-1">
{selectedEvents.map((e, i) => (
<div key={i} className={cn(
"flex items-start gap-3 rounded-xl p-3",
e.type === "payment" ? "bg-indigo-500/5" : "bg-amber-500/5"
)}>
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border",
e.type === "payment"
? "border-indigo-500/20 bg-indigo-500/10 text-indigo-400"
: "border-amber-500/20 bg-amber-500/10 text-amber-400"
)}>
{e.type === "payment" ? <CreditCard className="h-3.5 w-3.5" /> : <FileText className="h-3.5 w-3.5" />}
</div>
<div className="flex-1 min-w-0">
{e.type === "payment" ? (
<>
<p className="text-xs font-semibold text-white">{e.item.tenant?.first_name} {e.item.tenant?.last_name}</p>
<p className="text-xs text-white/40">{formatCurrency(e.item.amount)} due</p>
<span className={cn(
"mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-medium capitalize",
e.item.status === "paid" ? "bg-emerald-500/15 text-emerald-400" :
e.item.status === "overdue" ? "bg-red-500/15 text-red-400" :
"bg-white/10 text-white/40"
)}>
{e.item.status}
</span>
</>
) : (
<>
<p className="text-xs font-semibold text-white">Lease Expiry</p>
<p className="text-xs text-white/40">{e.item.tenant?.first_name} {e.item.tenant?.last_name}</p>
<p className="text-xs text-white/30">{e.item.property?.name}</p>
</>
<div className="flex h-[520px] items-center justify-center text-sm text-white/30">Loading calendar</div>
)}
</div>
</div>
))}
</div>
)}
{/* Legend */}
<div className="border-t border-white/[0.06] px-5 py-3 space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wider text-white/20 mb-2">Legend</p>
{[
{ 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) => (
<div key={l.label} className="flex items-center gap-2">
<div className={cn("h-2 w-2 rounded-full", l.color)} />
<span className="text-xs text-white/40">{l.label}</span>
{selected && <EventDetail ev={selected} onClose={() => setSelected(null)} />}
{showSubscribe && <SubscribeModal url={subscribeUrl} onClose={() => setShowSubscribe(false)} />}
</div>
))}
)
}
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 (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-[#16161f] p-5" onClick={(e) => e.stopPropagation()}>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border" style={{ borderColor: m.color + "33", background: m.color + "1a", color: m.color }}>
<m.Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold text-white">{ev.title}</p>
<p className="text-xs text-white/40">{m.label}</p>
</div>
</div>
<button onClick={onClose} className="text-white/30 transition hover:text-white"><X className="h-4 w-4" /></button>
</div>
<dl className="space-y-2 text-sm">
<div className="flex justify-between gap-4"><dt className="text-white/40">Date</dt><dd className="text-white/80">{dateLabel}</dd></div>
{ev.subtitle && <div className="flex justify-between gap-4"><dt className="text-white/40">Details</dt><dd className="text-right text-white/80">{ev.subtitle}</dd></div>}
{ev.status && <div className="flex justify-between gap-4"><dt className="text-white/40">Status</dt><dd className="capitalize text-white/80">{ev.status}</dd></div>}
</dl>
<Link href={ev.href} className="mt-5 flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-500">
Open {m.label.toLowerCase()} <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
)
}
function SubscribeModal({ url, onClose }: { url: string; onClose: () => void }) {
const [copied, setCopied] = useState(false)
const webcal = url.replace(/^https?:\/\//, "webcal://")
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-lg rounded-2xl border border-white/10 bg-[#16161f] p-6" onClick={(e) => e.stopPropagation()}>
<div className="mb-4 flex items-start justify-between">
<div>
<h3 className="text-base font-bold text-white">Subscribe to your calendar</h3>
<p className="mt-1 text-xs text-white/45">One-way sync new rent, lease, and inspection dates appear automatically in your calendar app.</p>
</div>
<button onClick={onClose} className="text-white/30 transition hover:text-white"><X className="h-4 w-4" /></button>
</div>
{url ? (
<>
<div className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] p-2">
<input readOnly value={url} className="flex-1 bg-transparent px-2 text-xs text-white/70 outline-none" onFocus={(e) => e.currentTarget.select()} />
<button
onClick={() => { navigator.clipboard.writeText(url).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 2000) }}
className="flex items-center gap-1 rounded-md bg-indigo-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
{copied ? <><Check className="h-3 w-3" /> Copied</> : <><Copy className="h-3 w-3" /> Copy</>}
</button>
</div>
<div className="mt-4 space-y-2.5 text-xs text-white/55">
<p><strong className="text-white/80">Google Calendar:</strong> Settings Add calendar <em>From URL</em> paste the link.</p>
<p><strong className="text-white/80">Apple Calendar:</strong> File New Calendar Subscription paste, or open <a href={webcal} className="text-indigo-400 hover:text-indigo-300">this webcal link</a>.</p>
<p><strong className="text-white/80">Outlook:</strong> Add calendar Subscribe from web paste the link.</p>
</div>
<div className="mt-4 flex items-center justify-between border-t border-white/[0.06] pt-4">
<p className="text-[11px] text-white/30">Keep this link private anyone with it can view your dates.</p>
<a href={url} download className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/[0.06]">Download .ics</a>
</div>
</>
) : (
<p className="text-sm text-white/50">Your feed link isn&apos;t available yet. Refresh the page and try again.</p>
)}
</div>
</div>
)
+147
View File
@@ -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);
}
+96 -23
View File
@@ -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 (
<div className="max-w-5xl mx-auto">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h2 className="text-lg font-bold text-white">Calendar</h2>
<p className="text-sm text-white/40 mt-0.5">Rent due dates and lease expirations at a glance</p>
<p className="text-sm text-white/40 mt-0.5">
Rent due dates, lease expirations, and inspections subscribe to sync with Google, Apple, or Outlook.
</p>
</div>
<CalendarClient payments={payments ?? []} leases={leaseList ?? []} />
<CalendarClient events={events} canWrite={ctx.canWrite} subscribeUrl={subscribeUrl} />
</div>
)
}
+19 -18
View File
@@ -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,21 +34,12 @@ function GreetingBanner({ name }: { name: string }) {
const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-8">
<div>
<div className="mb-8">
<h2 className="text-xl font-bold text-white">
{greeting}, {name?.split(" ")[0] ?? "there"} 👋
</h2>
<p className="text-sm text-white/40 mt-0.5">{dateStr}</p>
</div>
<div className="flex items-center gap-2 rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-2">
<div className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-400" />
</div>
<span className="text-xs font-medium text-emerald-400">All systems operational</span>
</div>
</div>
)
}
@@ -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
+4 -1
View File
@@ -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 } },
+5 -2
View File
@@ -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)),
])
+5 -2
View File
@@ -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),
])
+5 -2
View File
@@ -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),
])
@@ -20,7 +20,7 @@ const typeColors: Record<string, string> = {
const statusIcon: Record<string, React.ElementType> = {
draft: Clock,
completed: CheckCircle2,
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
<button
onClick={() => toggleStatus(ins.id, ins.status)}
className="flex items-center gap-1 text-xs hover:opacity-80 transition"
title={ins.status === "completed" ? "Mark as draft" : "Mark as complete"}
title={ins.status === "complete" ? "Mark as draft" : "Mark as complete"}
>
<StatusIcon className={`h-3.5 w-3.5 ${ins.status === "completed" ? "text-emerald-400" : "text-white/30"}`} />
<StatusIcon className={`h-3.5 w-3.5 ${ins.status === "complete" ? "text-emerald-400" : "text-white/30"}`} />
<span className="text-white/30 capitalize">{ins.status}</span>
</button>
<button
+5 -2
View File
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { inspections, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { InspectionManager } from "./inspection-manager"
export const metadata = { title: "Inspections" }
@@ -11,9 +12,11 @@ export default async function InspectionsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const [inspectionList, propertyList] = await Promise.all([
db.query.inspections.findMany({
where: eq(inspections.user_id, user.id),
where: eq(inspections.user_id, ownerId),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
@@ -21,7 +24,7 @@ export default async function InspectionsPage() {
orderBy: desc(inspections.created_at),
}),
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 } },
+10 -2
View File
@@ -2,7 +2,9 @@ import { redirect } from "next/navigation"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSession } from "@/lib/session"
import { getSession, isAdminUser } from "@/lib/session"
import { getMaintenanceMode } from "@/lib/settings"
import { MaintenanceScreen } from "@/components/shared/maintenance-screen"
import { Sidebar } from "@/components/dashboard/sidebar"
import { Header } from "@/components/dashboard/header"
import { CommandPalette } from "@/components/dashboard/command-palette"
@@ -19,6 +21,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
redirect("/login")
}
// Site maintenance mode: everyone except admins sees the maintenance screen.
const maintenance = await getMaintenanceMode()
if (maintenance.enabled && !isAdminUser(user)) {
return <MaintenanceScreen message={maintenance.message} />
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
})
@@ -30,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<div className="flex h-screen flex-col bg-[#09090b] overflow-hidden">
{impersonating && <ImpersonationBanner label={profile?.email ?? user.email} />}
<div className="flex flex-1 overflow-hidden">
<Sidebar profile={profile ?? null} />
<Sidebar profile={profile ?? null} isAdmin={isAdminUser(user)} />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main id="main-scroll" className="flex-1 overflow-y-auto p-4 sm:p-6">
@@ -0,0 +1,76 @@
import { notFound, redirect } from "next/navigation"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants as tenantsTable, properties, leases as leasesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { LeaseForm } from "@/components/forms/lease-form"
import { BackButton } from "@/components/ui/back-button"
export const metadata = { title: "Edit Lease" }
export default async function EditLeasePage({ params }: { params: Promise<{ leaseId: string }> }) {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const { leaseId } = await params
const [lease, tenants, properties_] = await Promise.all([
db.query.leases.findFirst({
where: and(eq(leasesTable.id, leaseId), eq(leasesTable.user_id, ownerId)),
with: {
tenant: { columns: { id: true, first_name: true, last_name: true } },
},
}),
db
.select({
id: tenantsTable.id,
first_name: tenantsTable.first_name,
last_name: tenantsTable.last_name,
unit_id: tenantsTable.unit_id,
property_id: tenantsTable.property_id,
})
.from(tenantsTable)
.where(and(eq(tenantsTable.user_id, ownerId), eq(tenantsTable.status, "active")))
.orderBy(asc(tenantsTable.first_name)),
db.query.properties.findMany({
where: eq(properties.user_id, ownerId),
columns: { id: true, name: true },
with: {
units: { columns: { id: true, unit_number: true } },
},
orderBy: asc(properties.name),
}),
])
if (!lease) notFound()
// Ensure the lease's own tenant is selectable even if now inactive.
const tenantList = tenants.some((t) => t.id === lease.tenant_id)
? tenants
: [
{
id: lease.tenant_id,
first_name: lease.tenant?.first_name ?? "",
last_name: lease.tenant?.last_name ?? "",
unit_id: lease.unit_id,
property_id: lease.property_id,
},
...tenants,
]
return (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href={`/leases/${leaseId}`} />
<div>
<h2 className="text-lg font-semibold text-white">Edit Lease</h2>
<p className="text-sm text-white/40">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</p>
</div>
<LeaseForm tenants={tenantList} properties={properties_ ?? []} lease={lease} />
</div>
)
}
+225
View File
@@ -0,0 +1,225 @@
import { notFound, redirect } from "next/navigation"
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 { getAccountContext } from "@/lib/account"
import { listAdapters, listRequestsForLease } from "@/lib/esign"
import Link from "next/link"
import { FileText, ExternalLink } from "lucide-react"
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
import { cn } from "@/lib/utils"
import { LeaseActions } from "@/components/forms/lease-actions"
import { EsignLease } from "@/components/forms/esign-lease"
export const metadata = { title: "Lease" }
const statusColors: Record<string, string> = {
active: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
expired: "text-red-400 bg-red-500/10 border-red-500/20",
terminated: "text-white/40 bg-white/5 border-white/10",
renewed: "text-blue-400 bg-blue-500/10 border-blue-500/20",
}
const leaseTypeLabels: Record<string, string> = {
fixed: "Fixed Term",
month_to_month: "Month-to-Month",
}
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3">
<span className="text-sm text-white/40">{label}</span>
<span className="text-right text-sm font-medium text-white">{children}</span>
</div>
)
}
export default async function LeaseDetailPage({ params }: { params: Promise<{ leaseId: string }> }) {
const user = await getSessionUser()
if (!user) redirect("/login")
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
const { leaseId } = await params
const lease = await db.query.leases.findFirst({
where: and(eq(leasesTable.id, leaseId), eq(leasesTable.user_id, ownerId)),
with: {
tenant: { columns: { id: true, first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
if (!lease) notFound()
const esignRequests = await listRequestsForLease(ownerId, leaseId)
const esignProviders = listAdapters()
const canSendEsign = ctx.canWrite && !!lease.document_url && !!lease.tenant?.email
const esignDisabledReason = !ctx.canWrite
? "You have read-only access."
: !lease.document_url
? "Upload a lease document to enable e-signature."
: !lease.tenant?.email
? "The tenant has no email address on file."
: ""
const days = daysUntil(lease.lease_end)
const totalDays = Math.max(
0,
Math.round(
(new Date(lease.lease_end).getTime() - new Date(lease.lease_start).getTime()) /
(1000 * 60 * 60 * 24)
)
)
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-white/40">
<Link href="/leases" className="hover:text-white transition">Leases</Link>
<span>/</span>
<span className="text-white/70">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-indigo-500/20 bg-indigo-600/20 text-indigo-400">
<FileText className="h-6 w-6" />
</div>
<div>
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-white">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</h2>
<span className={cn("rounded-md border px-2 py-0.5 text-xs font-medium capitalize", statusColors[lease.status] ?? statusColors.active)}>
{lease.status}
</span>
</div>
<p className="mt-1 text-sm text-white/50">
{lease.property?.name} · Unit {lease.unit?.unit_number ?? "—"}
</p>
</div>
</div>
<LeaseActions lease={lease} />
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main */}
<div className="lg:col-span-2 space-y-6">
{/* Term */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h3 className="text-sm font-semibold text-white">Lease Term</h3>
</div>
<div className="divide-y divide-white/[0.04] px-5">
<InfoRow label="Start date">{formatDate(lease.lease_start)}</InfoRow>
<InfoRow label="End date">{formatDate(lease.lease_end)}</InfoRow>
<InfoRow label="Duration">{totalDays} days</InfoRow>
<InfoRow label={days <= 0 ? "Expired" : "Ends in"}>
{lease.status === "active"
? days <= 0
? <span className="text-red-400">{Math.abs(days)} days ago</span>
: <span className={days <= 30 ? "text-amber-400" : "text-white"}>{days} days</span>
: <span className="text-white/40"></span>}
</InfoRow>
</div>
</div>
{/* Financials */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h3 className="text-sm font-semibold text-white">Financials</h3>
</div>
<div className="divide-y divide-white/[0.04] px-5">
<InfoRow label="Monthly rent">
{formatCurrency(lease.rent_amount)}<span className="text-xs text-white/30">/mo</span>
</InfoRow>
<InfoRow label="Security deposit">
{lease.security_deposit != null ? formatCurrency(lease.security_deposit) : <span className="text-white/40"></span>}
</InfoRow>
</div>
</div>
{/* Notes */}
{lease.notes && (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h3 className="text-sm font-semibold text-white">Notes</h3>
</div>
<p className="whitespace-pre-wrap px-5 py-4 text-sm text-white/60">{lease.notes}</p>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
<h3 className="text-xs font-medium uppercase tracking-wider text-white/30">Details</h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-white/40">Tenant</span>
{lease.tenant?.id ? (
<Link href={`/tenants/${lease.tenant.id}`} className="text-sm font-medium text-indigo-400 hover:text-indigo-300 transition">
{lease.tenant.first_name} {lease.tenant.last_name}
</Link>
) : (
<span className="text-sm font-medium text-white">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</span>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white/40">Property</span>
<span className="text-sm font-medium text-white">{lease.property?.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white/40">Unit</span>
<span className="text-sm font-medium text-white">{lease.unit?.unit_number ?? "—"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white/40">Type</span>
<span className="text-sm font-medium text-white">{leaseTypeLabels[lease.lease_type] ?? lease.lease_type}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white/40">Auto-renew</span>
<span className={cn("text-sm font-medium", lease.auto_renew ? "text-emerald-400" : "text-white/40")}>
{lease.auto_renew ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</div>
{lease.document_url && (
<a
href={lease.document_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-xl border border-white/[0.06] bg-[#16161f] p-5 transition hover:border-white/15"
>
<div className="flex items-center gap-2 text-sm font-medium text-white">
<FileText className="h-4 w-4 text-indigo-400" />
Lease document
</div>
<ExternalLink className="h-4 w-4 text-white/40" />
</a>
)}
<EsignLease
leaseId={leaseId}
providers={esignProviders}
requests={esignRequests}
canSend={canSendEsign}
disabledReason={esignDisabledReason}
/>
</div>
</div>
</div>
)
}
+5 -2
View File
@@ -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 { LeaseForm } from "@/components/forms/lease-form"
import { BackButton } from "@/components/ui/back-button"
@@ -12,6 +13,8 @@ export default async function NewLeasePage({ searchParams }: { searchParams: Pro
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const params = await searchParams
const prefill = {
tenant_id: params.tenant_id ?? "",
@@ -30,10 +33,10 @@ export default async function NewLeasePage({ searchParams }: { searchParams: Pro
property_id: tenantsTable.property_id,
})
.from(tenantsTable)
.where(and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")))
.where(and(eq(tenantsTable.user_id, ownerId), eq(tenantsTable.status, "active")))
.orderBy(asc(tenantsTable.first_name)),
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 } },
+16 -5
View File
@@ -3,6 +3,7 @@ import { eq, asc } 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 Link from "next/link"
import { FileText, AlertTriangle, ArrowRight } from "lucide-react"
import { EmptyState } from "@/components/shared/empty-state"
@@ -34,8 +35,10 @@ export default async function LeasesPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const leases = await db.query.leases.findMany({
where: eq(leasesTable.user_id, user.id),
where: eq(leasesTable.user_id, ownerId),
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
@@ -90,9 +93,9 @@ export default async function LeasesPage() {
return (
<tr key={lease.id} className="group hover:bg-white/[0.02] transition">
<td className="px-5 py-3.5">
<p className="text-sm font-medium text-white">
<Link href={`/leases/${lease.id}`} className="text-sm font-medium text-white hover:text-indigo-300 transition">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</p>
</Link>
</td>
<td className="px-5 py-3.5">
<p className="text-sm text-white/70">{lease.property?.name}</p>
@@ -114,6 +117,7 @@ export default async function LeasesPage() {
<DaysChip days={days} status={lease.status} />
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-4">
{canRenew && (
<Link
href={`/leases/new?tenant_id=${lease.tenant_id}&property_id=${lease.property_id}&unit_id=${lease.unit_id ?? ""}&rent_amount=${lease.rent_amount}`}
@@ -122,6 +126,13 @@ export default async function LeasesPage() {
Renew <ArrowRight className="h-3 w-3" />
</Link>
)}
<Link
href={`/leases/${lease.id}`}
className="inline-flex items-center gap-1 text-xs font-medium text-white/50 hover:text-white transition"
>
View <ArrowRight className="h-3 w-3" />
</Link>
</div>
</td>
</tr>
)
@@ -138,14 +149,14 @@ export default async function LeasesPage() {
return (
<div key={lease.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<Link href={`/leases/${lease.id}`} className="min-w-0">
<p className="text-sm font-semibold text-white">
{lease.tenant?.first_name} {lease.tenant?.last_name}
</p>
<p className="text-xs text-white/40 mt-0.5">
{lease.property?.name} · Unit {lease.unit?.unit_number ?? "—"}
</p>
</div>
</Link>
<span className={cn("shrink-0 rounded-md border px-2 py-0.5 text-xs font-medium capitalize", statusColors[lease.status] ?? statusColors.active)}>
{lease.status}
</span>
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { maintenance_requests } 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 { MaintenanceStatusBadge, PriorityBadge } from "@/components/dashboard/maintenance-status-badge"
@@ -12,12 +13,14 @@ export default async function MaintenanceDetailPage({ params }: { params: Promis
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const { requestId } = await params
const req = await db.query.maintenance_requests.findFirst({
where: and(
eq(maintenance_requests.id, requestId),
eq(maintenance_requests.user_id, user.id)
eq(maintenance_requests.user_id, ownerId)
),
with: {
property: { columns: { name: true, address_line1: true, city: true } },
+5 -2
View File
@@ -3,6 +3,7 @@ import { and, asc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties, tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { MaintenanceForm } from "@/components/forms/maintenance-form"
import { BackButton } from "@/components/ui/back-button"
@@ -12,9 +13,11 @@ export default async function NewMaintenancePage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const [propertyList, tenantList] = await Promise.all([
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 } },
@@ -29,7 +32,7 @@ export default async function NewMaintenancePage() {
unit_id: tenants.unit_id,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
.where(and(eq(tenants.user_id, ownerId), eq(tenants.status, "active"))),
])
return (
+5 -2
View File
@@ -3,6 +3,7 @@ import { asc, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { maintenance_requests, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import Link from "next/link"
import { Plus } from "lucide-react"
import { MaintenanceList } from "./maintenance-list"
@@ -13,9 +14,11 @@ export default async function MaintenancePage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const [requests, propertyList] = await Promise.all([
db.query.maintenance_requests.findMany({
where: eq(maintenance_requests.user_id, user.id),
where: eq(maintenance_requests.user_id, ownerId),
with: {
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
@@ -26,7 +29,7 @@ export default async function MaintenancePage() {
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)),
])
+123
View File
@@ -0,0 +1,123 @@
import { redirect } from "next/navigation"
import Link from "next/link"
import { eq, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles, properties, tenants, leases, rent_payments } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { completeOnboarding } from "@/app/actions/onboarding"
import { Building2, Users, FileText, CreditCard, CheckCircle2, ArrowRight, Sparkles } from "lucide-react"
export const dynamic = "force-dynamic"
async function count(table: typeof properties | typeof tenants | typeof leases | typeof rent_payments, userId: string) {
const [row] = await db.select({ c: sql<number>`count(*)::int` }).from(table).where(eq(table.user_id, userId))
return row?.c ?? 0
}
export default async function OnboardingPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { onboarding_completed: true, full_name: true },
})
// Users who already finished onboarding don't need this screen.
if (profile?.onboarding_completed) redirect("/dashboard")
const [propCount, tenantCount, leaseCount, rentCount] = await Promise.all([
count(properties, user.id),
count(tenants, user.id),
count(leases, user.id),
count(rent_payments, user.id),
])
const steps = [
{ label: "Add your first property", desc: "Create a building or unit to manage.", href: "/properties/new", icon: Building2, done: propCount > 0 },
{ label: "Add a tenant", desc: "Record who's renting from you.", href: "/tenants/new", icon: Users, done: tenantCount > 0 },
{ label: "Set up a lease", desc: "Track term, rent, and deposit.", href: "/leases/new", icon: FileText, done: leaseCount > 0 },
{ label: "Record a rent payment", desc: "Log or generate the first payment.", href: "/rent/new", icon: CreditCard, done: rentCount > 0 },
]
const doneCount = steps.filter((s) => s.done).length
const firstName = profile?.full_name?.split(" ")[0]
return (
<div className="mx-auto max-w-2xl py-4">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600 shadow-lg shadow-indigo-500/25">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">
Welcome{firstName ? `, ${firstName}` : ""} 👋
</h1>
<p className="text-sm text-white/50">Let&apos;s get your portfolio set up in four quick steps.</p>
</div>
</div>
{/* Progress */}
<div className="mb-6">
<div className="mb-1.5 flex items-center justify-between text-xs text-white/50">
<span>{doneCount} of {steps.length} complete</span>
<span>{Math.round((doneCount / steps.length) * 100)}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/[0.06]">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all"
style={{ width: `${(doneCount / steps.length) * 100}%` }}
/>
</div>
</div>
{/* Steps */}
<ol className="space-y-3">
{steps.map((step, i) => (
<li
key={step.label}
className={`flex items-center gap-4 rounded-2xl border p-4 transition-colors ${
step.done
? "border-emerald-500/20 bg-emerald-500/[0.04]"
: "border-white/[0.06] bg-[#16161f]"
}`}
>
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${
step.done ? "bg-emerald-500/15 text-emerald-400" : "bg-white/[0.04] text-white/50"
}`}
>
{step.done ? <CheckCircle2 className="h-5 w-5" /> : <step.icon className="h-5 w-5" />}
</div>
<div className="min-w-0 flex-1">
<p className={`text-sm font-semibold ${step.done ? "text-white/60 line-through" : "text-white"}`}>
{i + 1}. {step.label}
</p>
<p className="text-xs text-white/40">{step.desc}</p>
</div>
{step.done ? (
<span className="shrink-0 text-xs font-medium text-emerald-400">Done</span>
) : (
<Link
href={step.href}
className="flex shrink-0 items-center gap-1 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
Add <ArrowRight className="h-3.5 w-3.5" />
</Link>
)}
</li>
))}
</ol>
{/* Finish */}
<form action={completeOnboarding} className="mt-6 flex items-center justify-between gap-3">
<p className="text-xs text-white/40">You can always finish these later from the dashboard.</p>
<button
type="submit"
className="shrink-0 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/10"
>
{doneCount === steps.length ? "Finish setup" : "Skip to dashboard"}
</button>
</form>
</div>
)
}
+4 -1
View File
@@ -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 <PredictionsClient predictions={predictions ?? []} />
@@ -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()
@@ -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,15 +172,40 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm font-semibold text-white">{formatCurrency(unit.rent_amount)}<span className="text-xs text-white/40">/mo</span></p>
</div>
<UnitActions propertyId={propertyId} unitId={unit.id} unitNumber={unit.unit_number} />
</div>
</div>
))}
</div>
)}
</div>
{/* Location */}
{property.latitude != null && property.longitude != null && (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<MapPin className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">Location</h3>
</div>
<PropertyMap
className="h-72 w-full"
markers={[
{
id: property.id,
name: property.name,
lat: property.latitude,
lng: property.longitude,
subtitle: `${property.address_line1}, ${property.city}${property.state ? `, ${property.state}` : ""}`,
},
]}
/>
</div>
)}
{/* Revenue chart */}
<PropertyRevenueChart data={chartData} />
@@ -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 (
<div className="mx-auto max-w-2xl space-y-6">
<BackButton href={`/properties/${propertyId}`} />
<div>
<h2 className="text-lg font-semibold text-white">Edit Unit {unit.unit_number}</h2>
<p className="text-sm text-white/40">{property.name}</p>
</div>
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
<UnitForm propertyId={propertyId} unit={unit} />
</div>
</div>
)
}
@@ -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 },
})
+22 -1
View File
@@ -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 (
<div className="space-y-6">
{/* Page header */}
@@ -39,6 +54,12 @@ export default async function PropertiesPage() {
</div>
</div>
{mapMarkers.length > 0 && (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<PropertyMap className="h-80 w-full" markers={mapMarkers} />
</div>
)}
{!properties?.length ? (
<EmptyState
icon={Building2}
+4 -1
View File
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_recommendations } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { RecommendationsClient } from "./recommendations-client"
export const metadata = { title: "AI Recommendations" }
@@ -11,10 +12,12 @@ export default async function RecommendationsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const ownerId = await getEffectiveOwnerId(user.id)
const recommendations = await db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.where(eq(ai_recommendations.user_id, ownerId))
.orderBy(desc(ai_recommendations.created_at))
return <RecommendationsClient recommendations={recommendations ?? []} />
+4 -1
View File
@@ -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 } },
+5 -2
View File
@@ -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 (
<div className="mx-auto max-w-3xl space-y-6">
+4 -1
View File
@@ -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 } },
+4 -1
View File
@@ -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 } },
+20 -3
View File
@@ -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 (
<div className="space-y-6">
{/* Header + CSV exports */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-white">Reports</h2>
<p className="text-sm text-white/40">Revenue, expenses &amp; profit last 6 months</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<CsvExportButton endpoint="/api/export/rent" filename="rent-payments.csv" label="Rent CSV" />
<CsvExportButton endpoint="/api/export/tenants" filename="tenants.csv" label="Tenants CSV" />
<CsvExportButton endpoint="/api/expenses/export" filename="expenses.csv" label="Expenses CSV" />
</div>
</div>
{/* Summary KPI cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
@@ -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 (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">API Keys</h2>
<p className="text-sm text-white/40">
Create keys to authenticate with the public REST API. See the{" "}
<Link
href="/api-docs"
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
>
API documentation
</Link>{" "}
for available endpoints.
</p>
</div>
<ApiKeyManager initialKeys={keys} />
</div>
)
}
+26 -5
View File
@@ -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.
</div>
)}
{params.error === "paypal" && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-5 py-4 text-sm text-red-400">
We couldn&apos;t complete your PayPal payment. No charge was made please try again.
</div>
)}
{/* Current plan */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
@@ -117,9 +129,12 @@ export default async function BillingPage({
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
)}
</div>
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
{currentPlan !== "starter" && currentPlan !== "lifetime" &&
(isPaypal ? (
<PaypalCancelButton />
) : hasStripeAccount ? (
<PortalButton />
)}
) : null)}
</div>
</div>
@@ -177,7 +192,13 @@ export default async function BillingPage({
{plan.key === "starter" ? "Free" : "Downgrade via portal"}
</div>
) : (
<CheckoutButton plan={plan.key} label={plan.cta} highlight={plan.highlight} />
<CheckoutButton
plan={plan.key}
label={plan.cta}
highlight={plan.highlight}
annualAvailable={canBillAnnually && plan.key !== "lifetime"}
paypalEnabled={paypalEnabled}
/>
)}
</div>
</div>
@@ -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 (
<div className="max-w-4xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">White-Label Branding</h2>
<p className="text-sm text-white/40">
Customize how the tenant portal looks with your own brand.
</p>
</div>
{hasWhiteLabel ? (
<BrandingForm
brandName={profile?.brand_name ?? null}
brandLogoUrl={profile?.brand_logo_url ?? null}
brandColor={profile?.brand_color ?? null}
hidePoweredBy={profile?.hide_powered_by ?? false}
/>
) : (
<div className="rounded-xl border border-indigo-500/20 bg-indigo-600/5 p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-indigo-600">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h3 className="text-base font-semibold text-white">White-label is a Landlord feature</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-white/50">
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.
</p>
<Link
href="/settings/billing"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500"
>
Upgrade to unlock
<ArrowRight className="h-4 w-4" />
</Link>
</div>
)}
</div>
)
}
+3 -2
View File
@@ -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),
@@ -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 (
<div className="max-w-3xl mx-auto space-y-6">
<div>
<h2 className="text-lg font-bold text-white">Integrations</h2>
<p className="text-sm text-white/40 mt-0.5">
Connect your accounting software to automatically push rent income and expenses into your books.
</p>
</div>
<AccountingIntegrations
providers={providers}
connections={connections}
isOwner={ctx.isOwner}
flash={{ connected: sp.connected, error: sp.error }}
/>
</div>
)
}
+120
View File
@@ -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 (
<div className="max-w-2xl space-y-6">
<PageHeader />
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-indigo-600/10 p-2">
<Users className="h-5 w-5 text-indigo-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
You&apos;re a {ctx.role} of {ownerName}&apos;s account
</p>
<p className="mt-1 text-sm text-white/50">
You&apos;re working inside {ownerName}&apos;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.
</p>
</div>
</div>
</div>
</div>
)
}
// 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 (
<div className="max-w-2xl space-y-6">
<PageHeader />
<div className="rounded-xl border border-indigo-500/20 bg-indigo-600/5 p-8 text-center">
<div className="mx-auto mb-4 w-fit rounded-xl bg-indigo-600/10 p-3">
<Users className="h-6 w-6 text-indigo-400" />
</div>
<h3 className="text-base font-semibold text-white">Team access is a paid feature</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-white/50">
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.
</p>
<Link
href="/settings/billing"
className="mt-6 inline-flex items-center justify-center rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500"
>
Upgrade your plan
</Link>
</div>
</div>
)
}
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 (
<div className="max-w-2xl space-y-6">
<PageHeader />
<TeamManager initialMembers={members} />
</div>
)
}
function PageHeader() {
return (
<div>
<h2 className="text-lg font-semibold text-white">Team Access</h2>
<p className="text-sm text-white/40">
Invite people to help manage your property portfolio
</p>
</div>
)
}
@@ -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 (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Webhooks</h2>
<p className="text-sm text-white/40">
Send real-time events to Zapier, Make, or your own server. Each delivery is signed with the
endpoint&apos;s secret so you can verify it&apos;s from us. See the{" "}
<Link
href="/api-docs#webhooks"
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
>
webhook documentation
</Link>{" "}
for the payload format and signature scheme.
</p>
</div>
<WebhookManager initialEndpoints={endpoints} />
</div>
)
}
@@ -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 } },
+14 -4
View File
@@ -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
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Link
href={`/tenants/${tenantId}/edit`}
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition"
>
Edit
</Link>
<DeleteTenantButton
tenantId={tenantId}
tenantName={`${tenant.first_name} ${tenant.last_name}`}
/>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
+4 -1
View File
@@ -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 } },
+4 -1
View File
@@ -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 } },
@@ -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[] }) {
</p>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-3">
<Link
href={`/tenants/${tenant.id}`}
className="inline-flex items-center gap-1 text-xs text-white/30 transition group-hover:text-indigo-400"
>
View <ArrowRight className="h-3 w-3" />
</Link>
<DeleteTenantButton
tenantId={tenant.id}
tenantName={`${tenant.first_name} ${tenant.last_name}`}
refreshOnly
compact
/>
</div>
</td>
</tr>
))}
+5 -2
View File
@@ -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 (
+101
View File
@@ -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 (
<LegalPage
title="Acceptable Use Policy"
subtitle={`This Acceptable Use Policy sets out the rules for using the ${LEGAL.service} platform. It is designed to keep the Service safe, lawful, and reliable for everyone.`}
>
<Section heading="1. Overview and scope">
<p>
This Acceptable Use Policy (the <strong>Policy</strong>) applies to all access to and use of the{" "}
{LEGAL.service} platform (the <strong>Service</strong>) operated by {LEGAL.entity} (<strong>we</strong>,{" "}
<strong>us</strong>, or <strong>our</strong>). It applies to you as the account holder (a landlord or
property manager), whom we refer to as <strong>you</strong> or <strong>Customer</strong>, and to anyone who
accesses the Service through your account.
</p>
<p>
This Policy forms part of, and is incorporated by reference into, our{" "}
<a href="/terms">Terms of Service</a>. 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.
</p>
</Section>
<Section heading="2. Prohibited activities">
<p>When using the Service, you must not, and must not permit any third party to:</p>
<ul>
<li>Use the Service for any illegal, fraudulent, or unauthorized purpose, or in any way that violates applicable law or regulation.</li>
<li>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.</li>
<li>Harass, threaten, defame, or otherwise engage in abusive or unlawful contact with any <strong>Tenant</strong> or other individual through the Service.</li>
<li>Upload, store, or transmit content that is infringing, unlawful, defamatory, obscene, or that contains viruses, malware, or other malicious code.</li>
<li>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.</li>
<li>Probe, scan, or test the vulnerability of the Service, or circumvent, disable, or otherwise interfere with any security features or access controls.</li>
<li>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.</li>
<li>Interfere with, disrupt, or place an unreasonable load on the Service or its infrastructure, or attempt to overload or degrade it.</li>
<li>Use the email or notification features of the Service to send spam, unsolicited bulk messages, or any unlawful, deceptive, or harassing communications.</li>
<li>Misuse <strong>Tenant</strong> 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.</li>
</ul>
</Section>
<Section heading="3. Data and privacy responsibilities">
<p>
You may only upload or process <strong>Tenant</strong> 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.
</p>
<p>
For personal data you process through the Service, you act as the <strong>data controller</strong> and are
responsible for complying with applicable data-protection laws. Our respective obligations are set out in
our <a href="/dpa">Data Processing Addendum</a>, and our data practices are described in our{" "}
<a href="/privacy">Privacy Policy</a>.
</p>
</Section>
<Section heading="4. Security and vulnerability reporting">
<p>
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{" "}
<a href={`mailto:${LEGAL.securityEmail}`}>{LEGAL.securityEmail}</a> 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.
</p>
</Section>
<Section heading="5. Usage limits and fair use">
<p>
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.
</p>
</Section>
<Section heading="6. Enforcement">
<p>
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 <a href="/terms">Terms of Service</a>.
</p>
</Section>
<Section heading="7. Reporting abuse">
<p>
If you become aware of any use of the Service that violates this Policy, please report it to us so that we
can investigate.
</p>
</Section>
<LegalContact email={LEGAL.contactEmail} />
</LegalPage>
)
}
+109 -27
View File
@@ -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<string, string> = {
@@ -41,7 +50,7 @@ export default function ApiDocsPage() {
</p>
<div className="mt-6 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2.5">
<span className="text-xs font-mono text-white/40">Base URL:</span>
<code className="text-xs font-mono text-indigo-300">https://api.propertymanagement.network/v1</code>
<code className="text-xs font-mono text-indigo-300">{BASE_URL}</code>
</div>
</div>
</div>
@@ -54,13 +63,15 @@ export default function ApiDocsPage() {
</h2>
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-6">
<p className="text-sm text-white/60 mb-4 leading-relaxed">
All API requests require a Bearer token in the Authorization header. Generate your API key from
the <Link href="/login" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">dashboard settings</Link>.
All API requests require a Bearer API key in the Authorization header. Keys look like{" "}
<code className="text-indigo-300 font-mono text-xs">pmn_live_</code> and are generated from{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">Settings API keys</Link>{" "}
inside your dashboard. The plaintext key is shown only once at creation, so store it securely.
</p>
<div className="rounded-xl bg-[#0a0a12] border border-white/[0.06] p-4 font-mono text-xs text-emerald-300">
<p className="text-white/30 mb-1"># Example request</p>
<p>curl https://api.propertymanagement.network/v1/properties \</p>
<p className="pl-4">-H &quot;Authorization: Bearer YOUR_API_KEY&quot;</p>
<p>curl {BASE_URL}/properties \</p>
<p className="pl-4">-H &quot;Authorization: Bearer pmn_live_...&quot;</p>
</div>
</div>
</div>
@@ -91,27 +102,98 @@ export default function ApiDocsPage() {
<BookOpen className="h-5 w-5 text-blue-400" /> Response Format
</h2>
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-6">
<p className="text-sm text-white/60 mb-4">All responses are JSON. Successful responses return a <code className="text-indigo-300 font-mono text-xs">data</code> field. Errors return an <code className="text-red-300 font-mono text-xs">error</code> field with a message and code.</p>
<p className="text-sm text-white/60 mb-4">
All responses are JSON. List endpoints return a <code className="text-indigo-300 font-mono text-xs">data</code> array
with a <code className="text-indigo-300 font-mono text-xs">count</code>. Single-record and create responses return a{" "}
<code className="text-indigo-300 font-mono text-xs">data</code> object (create returns HTTP 201). Errors return an{" "}
<code className="text-red-300 font-mono text-xs">error</code> object with a numeric <code className="text-red-300 font-mono text-xs">code</code> and a <code className="text-red-300 font-mono text-xs">message</code>.
</p>
<div className="rounded-xl bg-[#0a0a12] border border-white/[0.06] p-4 font-mono text-xs leading-relaxed">
<p className="text-white/30">// Success</p>
<p className="text-white/30">{"// Success (list)"}</p>
<p className="text-emerald-300">{"{"} &quot;data&quot;: [...], &quot;count&quot;: 12 {"}"}</p>
<br />
<p className="text-white/30">// Error</p>
<p className="text-white/30">{"// Success (single / create)"}</p>
<p className="text-emerald-300">{"{"} &quot;data&quot;: {"{"} ... {"}"} {"}"}</p>
<br />
<p className="text-white/30">{"// Error"}</p>
<p className="text-red-300">{"{"} &quot;error&quot;: {"{"} &quot;code&quot;: 401, &quot;message&quot;: &quot;Unauthorized&quot; {"}"} {"}"}</p>
</div>
</div>
</div>
{/* Coming soon banner */}
<div className="rounded-2xl border border-indigo-500/20 bg-indigo-500/5 p-6 text-center">
<p className="text-sm font-semibold text-indigo-300 mb-1">Full SDK coming soon</p>
<p className="text-xs text-white/40 mb-4">We&apos;re building official JavaScript and Python SDKs. Join the waitlist to be notified.</p>
<Link
href="/signup"
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2 text-xs font-semibold text-white hover:bg-indigo-500 transition"
{/* Webhooks */}
<div id="webhooks" className="scroll-mt-24">
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<Webhook className="h-5 w-5 text-emerald-400" /> Webhooks
</h2>
<div className="space-y-4">
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-6">
<p className="text-sm text-white/60 leading-relaxed">
Subscribe to real-time events instead of polling. Add endpoints in{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">Settings Webhooks</Link>{" "}
(or via the <code className="text-indigo-300 font-mono text-xs">/webhooks</code> API), and we&apos;ll POST a
signed JSON payload the moment something happens. This is the same mechanism that powers our{" "}
<span className="text-white/80 font-medium">Zapier</span> integration Zapier subscribes and unsubscribes
through the <code className="text-indigo-300 font-mono text-xs">POST /webhooks</code> and{" "}
<code className="text-indigo-300 font-mono text-xs">DELETE /webhooks/:id</code> endpoints (the REST Hook pattern).
</p>
</div>
{/* Events */}
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
<div className="border-b border-white/[0.06] px-6 py-3">
<p className="text-xs font-semibold uppercase tracking-widest text-white/40">Available events</p>
</div>
{WEBHOOK_EVENTS.map((ev, i) => (
<div
key={ev.id}
className={`flex items-start gap-4 px-6 py-3 ${i !== WEBHOOK_EVENTS.length - 1 ? "border-b border-white/[0.04]" : ""}`}
>
Join waitlist <ArrowRight className="h-3.5 w-3.5" />
</Link>
<code className="shrink-0 rounded-md bg-emerald-500/10 px-2.5 py-1 text-[11px] font-bold font-mono text-emerald-400">
{ev.id}
</code>
<p className="text-xs text-white/50 mt-0.5">{ev.description}</p>
</div>
))}
</div>
{/* Payload + signature */}
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-6">
<p className="text-sm font-semibold text-white mb-2">Payload &amp; signature</p>
<p className="text-sm text-white/60 mb-4 leading-relaxed">
Each request body is a JSON envelope. Every delivery carries an{" "}
<code className="text-indigo-300 font-mono text-xs">X-PMN-Signature</code> header {" "}
<code className="text-indigo-300 font-mono text-xs">t=&lt;unix&gt;,v1=&lt;hex&gt;</code> where{" "}
<code className="text-indigo-300 font-mono text-xs">v1</code> is the HMAC-SHA256 of{" "}
<code className="text-indigo-300 font-mono text-xs">{"`${t}.${rawBody}`"}</code> keyed with your endpoint&apos;s
signing secret. Recompute it and compare in constant time; reject if the timestamp is stale.
</p>
<div className="rounded-xl bg-[#0a0a12] border border-white/[0.06] p-4 font-mono text-xs leading-relaxed text-white/70">
<p className="text-white/30">{"// POST body"}</p>
<p className="text-emerald-300">{"{"}</p>
<p className="pl-4">&quot;id&quot;: &quot;evt_9f2c&quot;,</p>
<p className="pl-4">&quot;event&quot;: &quot;tenant.created&quot;,</p>
<p className="pl-4">&quot;created_at&quot;: &quot;2026-07-02T12:00:00.000Z&quot;,</p>
<p className="pl-4">&quot;data&quot;: {"{"} &quot;tenant&quot;: {"{"} {"}"} {"}"}</p>
<p className="text-emerald-300">{"}"}</p>
<p className="mt-2 text-white/30">{"# Headers"}</p>
<p>X-PMN-Event: tenant.created</p>
<p>X-PMN-Delivery: &lt;delivery id&gt;</p>
<p>X-PMN-Signature: t=1751457600,v1=1a2b3c</p>
</div>
<p className="text-xs text-white/40 mt-4">
Respond with any <span className="text-white/70">2xx</span> to acknowledge. Non-2xx or timeouts are retried
with exponential backoff (up to 5 attempts); a test event is available from the dashboard.
</p>
</div>
</div>
</div>
{/* SDK note */}
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6 text-center">
<p className="text-xs text-white/40">
Official client SDKs are not yet available. Call the endpoints directly over HTTP with any language or HTTP client.
</p>
</div>
</div>
</div>
+105 -53
View File
@@ -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 (
<div className="bg-[#09090b] text-white min-h-screen">
<div className="mx-auto max-w-3xl px-6 pt-32 pb-24">
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">Cookie Policy</h1>
<p className="text-xs text-white/30">Last updated: April 2026</p>
</div>
<LegalPage
title="Cookie Policy"
subtitle="This policy explains how we use cookies and similar technologies within the Service."
>
<Section heading="What are cookies">
<p>
Cookies are small text files that a website stores on your device through your browser.
Similar technologies, such as browser <strong>localStorage</strong>, 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{" "}
<strong>{LEGAL.entity}</strong> uses them within <strong>{LEGAL.service}</strong> (the{" "}
<strong>Service</strong>).
</p>
</Section>
<div className="space-y-8">
{SECTIONS.map((s) => (
<div key={s.title}>
<h2 className="text-base font-semibold text-white mb-2">{s.title}</h2>
<p className="text-sm text-white/50 leading-relaxed">{s.body}</p>
</div>
))}
</div>
<Section heading="Cookies and similar technologies we use">
<p>We use only the following limited set of technologies:</p>
<ul>
<li>
<strong>Strictly necessary authentication and session cookies</strong>&mdash;required to
sign you in and to maintain your secure session. The Service cannot function without
these.
</li>
<li>
<strong>Preference storage in browser localStorage</strong>&mdash;used to remember
interface preferences, such as layout and theme, on your device.
</li>
<li>
<strong>Stripe cookies</strong>&mdash;set by Stripe during payment to help prevent fraud
and to support secure checkout.
</li>
<li>
<strong>Cloudflare Turnstile challenge cookie</strong>&mdash;a challenge cookie that may
be set on authentication pages to distinguish genuine users from automated bots.
</li>
</ul>
</Section>
{/* Cookie types summary table */}
<div className="mt-10 rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
<Section heading="What we do not use">
<p>
We do <strong>not</strong> use advertising, retargeting, or cross-site tracking cookies, and
we do <strong>not</strong> use third-party analytics cookies. We do not build advertising
profiles or share cookie data with advertising networks.
</p>
</Section>
<Section heading="Managing cookies">
<p>
Most browsers allow you to view, block, or delete cookies through their settings. Because
our authentication and session cookies are <strong>strictly necessary</strong>, 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.
</p>
</Section>
<Section heading="Retention">
<p>
<strong>Session</strong> cookies are temporary and are cleared when your session ends, while{" "}
<strong>persistent</strong> 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.
</p>
</Section>
<Section heading="Changes">
<p>
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.
</p>
</Section>
<LegalContact email={LEGAL.privacyEmail} />
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.04]">
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">Cookie Summary</p>
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">
Cookie summary
</p>
</div>
{[
{ 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) => (
<div key={row.name} className={`grid grid-cols-4 gap-4 px-5 py-3.5 text-xs ${i !== arr.length - 1 ? "border-b border-white/[0.04]" : ""}`}>
{COOKIE_ROWS.map((row, i, arr) => (
<div
key={row.name}
className={`grid grid-cols-1 gap-1 px-5 py-3.5 text-xs sm:grid-cols-4 sm:gap-4 ${
i !== arr.length - 1 ? "border-b border-white/[0.04]" : ""
}`}
>
<code className="font-mono text-indigo-300">{row.name}</code>
<span className="text-white/60">{row.type}</span>
<span className="text-white/40 col-span-1">{row.purpose}</span>
<span className="text-white/40 sm:col-span-1">{row.purpose}</span>
<span className="text-white/40">{row.expires}</span>
</div>
))}
</div>
</div>
</div>
</LegalPage>
)
}
+94
View File
@@ -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 (
<LegalPage
title="Disclaimer"
subtitle="Please read these important limitations regarding the information and outputs provided by the Service."
>
<Callout>
<strong>{LEGAL.service}</strong> (the <strong>Service</strong>) and all of its outputs are
provided for general informational purposes only and do <strong>not</strong> constitute
professional advice. You should not rely on them as a substitute for advice from a qualified
professional.
</Callout>
<Section heading="General information only">
<p>
The information made available through the Service is provided by{" "}
<strong>{LEGAL.entity}</strong> (<strong>we</strong>, <strong>us</strong>, or{" "}
<strong>our</strong>) 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.
</p>
</Section>
<Section heading="No professional advice">
<p>
Nothing provided through the Service constitutes <strong>legal</strong>,{" "}
<strong>tax</strong>, <strong>financial</strong>, <strong>accounting</strong>, or{" "}
<strong>real-estate</strong> advice. You (the account holder, referred to as{" "}
<strong>you</strong> or the <strong>Customer</strong>) should consult qualified
professionals before making any decision based on the Service.
</p>
</Section>
<Section heading="AI-generated content">
<p>
Some features generate content using artificial intelligence. AI-generated content may be{" "}
<strong>inaccurate, incomplete, or outdated</strong>, and may not reflect your specific
circumstances. You must independently verify any AI-generated content before relying on it.
The <strong>Customer</strong> is solely responsible for any decision made using such
content.
</p>
</Section>
<Section heading="Financial figures and reports">
<p>
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 <strong>not</strong> a substitute for professional accounting, bookkeeping, or audit
services.
</p>
</Section>
<Section heading="Legal and regulatory compliance">
<p>
The <strong>Customer</strong> is solely responsible for complying with all applicable laws
and regulations, including <strong>housing</strong>, <strong>fair-housing</strong>,{" "}
<strong>landlord-tenant</strong>, <strong>tax</strong>, and{" "}
<strong>data-protection</strong> laws. The Service is a tool to assist you and does not
ensure or guarantee your compliance with any legal obligation.
</p>
</Section>
<Section heading="Third-party content and links">
<p>
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.
</p>
</Section>
<Section heading="No warranty and limitation of liability">
<p>
The Service is provided on an <strong>&ldquo;as is&rdquo;</strong> and{" "}
<strong>&ldquo;as available&rdquo;</strong> 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 <a href="/terms">Terms of Service</a>, which govern your use of the Service.
</p>
</Section>
<LegalContact email={LEGAL.legalEmail} />
</LegalPage>
)
}
+247
View File
@@ -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 (
<LegalPage
title="Data Processing Addendum"
subtitle={`This Data Processing Addendum sets out the terms on which ${LEGAL.entity} processes personal data on behalf of Customers of ${LEGAL.service}.`}
>
<Callout>
This DPA forms part of the{" "}
<Link href="/terms">Terms of Service</Link> between you and {LEGAL.entity} and
applies wherever we process personal data on the Customer&rsquo;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.
</Callout>
<Section id="roles" heading="1. Introduction and roles">
<p>
In this DPA, <strong>Customer</strong> (also <strong>you</strong>) means the
account holder using {LEGAL.service}. <strong>We</strong>, <strong>us</strong>,
and <strong>our</strong> mean {LEGAL.entity}. A <strong>Tenant</strong> means a
data subject whose personal data the Customer manages through the Service.
</p>
<p>
With respect to Tenant personal data and other personal data that the Customer
submits to the Service, the Customer acts as the data{" "}
<strong>controller</strong> and we act as the data{" "}
<strong>processor</strong>, processing that personal data solely on the
Customer&rsquo;s behalf. With respect to the Customer&rsquo;s own account data
(for example, the name and contact details of the account holder and billing
information), we act as a <strong>controller</strong> in our own right, as
described in our{" "}
<Link href="/privacy">Privacy Policy</Link>.
</p>
</Section>
<Section id="definitions" heading="2. Definitions">
<p>Unless otherwise defined here, the following terms have the meanings given below:</p>
<ul>
<li>
<strong>Controller</strong> means the entity that determines the purposes and
means of the processing of personal data.
</li>
<li>
<strong>Processor</strong> means the entity that processes personal data on
behalf of the controller.
</li>
<li>
<strong>Personal Data</strong> means any information relating to an identified
or identifiable natural person that is processed under this DPA.
</li>
<li>
<strong>Data Subject</strong> means the identified or identifiable natural
person to whom Personal Data relates.
</li>
<li>
<strong>Processing</strong> means any operation performed on Personal Data,
whether or not by automated means, including collection, storage, use, and
deletion.
</li>
<li>
<strong>Sub-processor</strong> means any third party engaged by us to process
Personal Data on behalf of the Customer.
</li>
<li>
<strong>Applicable Data Protection Law</strong> 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{" "}
<strong>GDPR</strong>) and the United Kingdom General Data Protection
Regulation (the <strong>UK GDPR</strong>).
</li>
<li>
<strong>Standard Contractual Clauses</strong> 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.
</li>
</ul>
</Section>
<Section id="details" heading="3. Details of the processing">
<p>
The subject matter, duration, nature, and purpose of the processing, and the
types of Personal Data and categories of Data Subjects, are as follows:
</p>
<ul>
<li>
<strong>Subject matter:</strong> the provision of the Service to the Customer.
</li>
<li>
<strong>Duration:</strong> the term of the agreement between the Customer and
us, plus the deletion window described in Section 11.
</li>
<li>
<strong>Nature and purpose:</strong> hosting, storage, and processing of
Personal Data as necessary to operate the property-management features of the
Service.
</li>
<li>
<strong>Types of Personal Data:</strong> names, contact details, tenancy
information, lease information, and payment-status data.
</li>
<li>
<strong>Categories of Data Subjects:</strong> the Customer&rsquo;s Tenants and
contacts.
</li>
</ul>
</Section>
<Section id="obligations" heading="4. Our obligations as processor">
<p>When acting as a processor on the Customer&rsquo;s behalf, we shall:</p>
<ul>
<li>
process Personal Data only on the Customer&rsquo;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);
</li>
<li>
ensure that persons authorized to process Personal Data have committed
themselves to confidentiality or are under an appropriate statutory obligation
of confidentiality;
</li>
<li>
implement appropriate technical and organizational measures to ensure a level
of security appropriate to the risk, in accordance with Article 32 of the
GDPR;
</li>
<li>
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;
</li>
<li>
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;
</li>
<li>
make available to the Customer the information necessary to demonstrate
compliance with the obligations set out in this DPA.
</li>
</ul>
</Section>
<Section id="subprocessors" heading="5. Sub-processors">
<p>
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{" "}
<Link href="/subprocessors">Sub-processors</Link> page.
</p>
<p>
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&rsquo;s obligations.
</p>
</Section>
<Section id="transfers" heading="6. International transfers">
<p>
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.
</p>
</Section>
<Section id="rights" heading="7. Data-subject rights">
<p>
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&rsquo;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&rsquo;s behalf, we shall,
unless legally required to respond, forward that request to the Customer without
undue delay.
</p>
</Section>
<Section id="breach" heading="8. Personal-data breach">
<p>
We shall notify the Customer without undue delay after becoming aware of a
personal-data breach affecting Personal Data processed on the Customer&rsquo;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.
</p>
</Section>
<Section id="audit" heading="9. Audit">
<p>
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.
</p>
</Section>
<Section id="deletion" heading="10. Return and deletion">
<p>
Upon termination or expiry of the agreement, we shall, at the Customer&rsquo;s
choice, delete or return all Personal Data processed on the Customer&rsquo;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.
</p>
</Section>
<Section id="liability" heading="11. Liability">
<p>
Each party&rsquo;s liability under or in connection with this DPA is subject to
the exclusions and limitations of liability set out in the{" "}
<Link href="/terms">Terms of Service</Link>.
</p>
</Section>
<Section id="execution" heading="12. Execution">
<p>
This DPA is incorporated into, and forms part of, the Terms of Service and takes
effect upon the Customer&rsquo;s acceptance of the Terms and use of the Service.
A countersigned copy of this DPA is available on request by contacting{" "}
<a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>.
</p>
</Section>
<LegalContact email={LEGAL.dpoEmail} />
</LegalPage>
)
}
+132 -115
View File
@@ -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 (
<div className="bg-[#09090b] text-white min-h-screen">
<div className="mx-auto max-w-3xl px-6 pt-32 pb-24">
<div className="mb-10">
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-300 mb-4">
EU GDPR Compliant
</div>
<h1 className="text-3xl font-bold text-white mb-2">GDPR Compliance</h1>
<p className="text-xs text-white/30">Last updated: April 2026</p>
</div>
<div className="space-y-8 text-sm text-white/50 leading-relaxed">
<div>
<h2 className="text-base font-semibold text-white mb-2">Who we are</h2>
<LegalPage
title="GDPR & Data Rights"
subtitle={`Our commitment to the General Data Protection Regulation and the UK GDPR, and the rights available to you when ${LEGAL.entity} processes your personal data.`}
>
<Section id="introduction" heading="1. Introduction">
<p>
Property Management Network (&quot;we&quot;, &quot;us&quot;, &quot;our&quot;) 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.
{LEGAL.entity} is committed to protecting personal data and to complying with the
General Data Protection Regulation (Regulation (EU) 2016/679) (the{" "}
<strong>GDPR</strong>) and the United Kingdom General Data Protection Regulation
(the <strong>UK GDPR</strong>) 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}.
</p>
</div>
</Section>
<div>
<h2 className="text-base font-semibold text-white mb-2">What data we process</h2>
<p className="mb-3">We process the following categories of personal data:</p>
<ul className="space-y-2">
{[
"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) => (
<li key={item} className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-400" />
{item}
<Section id="rights" heading="2. Your rights under the GDPR and UK GDPR">
<p>
Subject to the conditions in Applicable Data Protection Law, you have the
following rights:
</p>
<ul>
<li>
<strong>Right of access</strong> &mdash; to obtain confirmation of whether we
process your personal data and to receive a copy of it.
</li>
<li>
<strong>Right to rectification</strong> &mdash; to have inaccurate personal
data corrected and incomplete data completed.
</li>
<li>
<strong>Right to erasure</strong> &mdash; to have your personal data deleted in
certain circumstances.
</li>
<li>
<strong>Right to restriction of processing</strong> &mdash; to limit how we
process your personal data in certain circumstances.
</li>
<li>
<strong>Right to data portability</strong> &mdash; to receive your personal
data in a structured, commonly used, machine-readable format.
</li>
<li>
<strong>Right to object</strong> &mdash; to object to processing that relies on
our legitimate interests.
</li>
<li>
<strong>Right to withdraw consent</strong> &mdash; where processing is based on
consent, to withdraw that consent at any time.
</li>
<li>
<strong>Right to lodge a complaint</strong> &mdash; to lodge a complaint with a
supervisory authority.
</li>
))}
</ul>
</div>
</Section>
<div>
<h2 className="text-base font-semibold text-white mb-2">Legal basis for processing</h2>
<Section id="legal-bases" heading="3. Legal bases for processing">
<p>
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.
We process personal data on one or more of the following legal bases, depending
on the context:
</p>
</div>
<ul>
<li>
<strong>Contract</strong> &mdash; where processing is necessary to provide the
Service you have requested.
</li>
<li>
<strong>Legitimate interests</strong> &mdash; where processing is necessary for
our legitimate interests, such as securing and improving the Service, provided
those interests are not overridden by your rights.
</li>
<li>
<strong>Consent</strong> &mdash; where you have given consent for a specific
purpose.
</li>
<li>
<strong>Legal obligation</strong> &mdash; where processing is necessary to
comply with a legal obligation to which we are subject.
</li>
</ul>
</Section>
<div>
<h2 className="text-base font-semibold text-white mb-2">Data storage and transfers</h2>
<Section id="storage" heading="4. Data storage and international transfers">
<p>
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).
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.
</p>
</div>
</Section>
<div>
<h2 className="text-base font-semibold text-white mb-2">Your rights under GDPR</h2>
<p className="mb-5">As a data subject, you have the following rights:</p>
<div className="space-y-4">
{RIGHTS.map((r) => (
<div key={r.right} className="rounded-xl border border-white/[0.06] bg-[#111118] p-4">
<p className="text-sm font-semibold text-white mb-1">{r.right}</p>
<p className="text-xs text-white/50 leading-relaxed">{r.desc}</p>
</div>
))}
</div>
</div>
<div>
<h2 className="text-base font-semibold text-white mb-2">Data retention</h2>
<Section id="retention" heading="5. Data retention">
<p>
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 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).
</p>
</div>
</Section>
<div>
<h2 className="text-base font-semibold text-white mb-2">Data breach notification</h2>
<Section id="breach" heading="6. Personal-data breach notification">
<p>
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.
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.
</p>
</div>
</Section>
<div>
<h2 className="text-base font-semibold text-white mb-2">Sub-processors</h2>
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
{[
{ 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) => (
<div key={sp.name} className={`grid grid-cols-3 gap-4 px-5 py-3.5 text-xs ${i !== arr.length - 1 ? "border-b border-white/[0.04]" : ""}`}>
<span className="font-semibold text-white">{sp.name}</span>
<span className="text-white/50">{sp.purpose}</span>
<span className="text-white/40">{sp.location}</span>
</div>
))}
</div>
</div>
<div>
<h2 className="text-base font-semibold text-white mb-2">Contact & complaints</h2>
<Section id="subprocessors" heading="7. Sub-processors">
<p>
To exercise any of your rights or to raise a data protection concern, contact our Data Protection lead at{" "}
<a href="mailto:privacy@propertymanagement.network" className="text-indigo-400 hover:text-indigo-300">
privacy@propertymanagement.network
</a>
. 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).
We use vetted third-party sub-processors to help operate the Service. Our current
sub-processors are listed on our{" "}
<Link href="/subprocessors">Sub-processors</Link> page, and the terms governing
their engagement are set out in our{" "}
<Link href="/dpa">Data Processing Addendum</Link>.
</p>
<p className="mt-4">
See also our{" "}
<Link href="/privacy" className="text-indigo-400 hover:text-indigo-300">Privacy Policy</Link>{" "}
and{" "}
<Link href="/cookie-policy" className="text-indigo-400 hover:text-indigo-300">Cookie Policy</Link>.
</Section>
<Section id="exercise" heading="8. How to exercise your rights">
<p>
To exercise any of the rights described above, contact us at{" "}
<a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For
data-protection matters, you may also contact our data-protection team at{" "}
<a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>. You also have the right
to lodge a complaint with your local supervisory authority (for example, the
Information Commissioner&rsquo;s Office in the United Kingdom, or your national
data-protection authority in the European Union).
</p>
</div>
</div>
</div>
</div>
</Section>
<Section id="roles" heading="9. Roles: controller versus processor">
<p>
Where you use the Service to manage the personal data of your Tenants, you act as
the data <strong>controller</strong> and we act as the data{" "}
<strong>processor</strong>, processing that personal data on your documented
instructions under our{" "}
<Link href="/dpa">Data Processing Addendum</Link>. Where we process your own
account data, we act as a <strong>controller</strong>, as described in our{" "}
<Link href="/privacy">Privacy Policy</Link>.
</p>
</Section>
<LegalContact email={LEGAL.dpoEmail} />
</LegalPage>
)
}
+15 -1
View File
@@ -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 <MaintenanceScreen message={maintenance.message} />
}
}
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[#09090b] text-white">
<StructuredData />
<Navbar />
{children}
<Footer />
+11 -3
View File
@@ -7,10 +7,18 @@ import { Testimonials } from "@/components/marketing/testimonials"
import { PricingSection } from "@/components/marketing/pricing-section"
import { FAQ } from "@/components/marketing/faq"
import { CtaBanner } from "@/components/marketing/cta-banner"
import { annualEnabled } from "@/lib/stripe/plans"
export const metadata = {
title: "Property Management Network — Property management without the chaos",
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised. Built for independent landlords.",
title: { absolute: "Property Management Software for Independent Landlords" },
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
alternates: { canonical: "/" },
openGraph: {
title: "Property management without the chaos",
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
url: "/",
type: "website",
},
}
export default function LandingPage() {
@@ -22,7 +30,7 @@ export default function LandingPage() {
<Features />
<HowItWorks />
<Testimonials />
<PricingSection />
<PricingSection annualEnabled={annualEnabled()} />
<FAQ />
<CtaBanner />
</>
+249 -12
View File
@@ -1,17 +1,254 @@
export const metadata = { title: "Privacy Policy — Property Management Network" }
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
export default function PrivacyPage() {
export const metadata = {
title: "Privacy Policy",
description:
"How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.",
alternates: { canonical: "/privacy" },
}
export default function Page() {
return (
<div className="mx-auto max-w-3xl px-6 py-24">
<h1 className="text-3xl font-bold text-white mb-8">Privacy Policy</h1>
<p className="text-white/50 text-sm leading-relaxed">
This Privacy Policy describes how Property Management Network collects, uses, and protects your information.
We collect only the data necessary to provide the service (account info, property data you enter,
and usage analytics). Your data is stored securely in Supabase with row-level security
no other user can access your records. We do not sell your data to third parties.
Files you upload are stored in private buckets and accessible only to you.
For questions, contact us at support@propertymanagement.network.
<LegalPage
title="Privacy Policy"
subtitle="This policy explains what personal data we process, why, how we protect it, and the rights you may exercise."
>
<Section heading="Introduction and scope">
<p>
<strong>{LEGAL.service}</strong> (the <strong>Service</strong>) is operated by{" "}
<strong>{LEGAL.entity}</strong> (<strong>we</strong>, <strong>us</strong>, or{" "}
<strong>our</strong>). This Privacy Policy describes how we handle personal data when you
(the account holder, referred to as <strong>you</strong> or the{" "}
<strong>Customer</strong>&mdash;typically a landlord or property manager) use the Service,
and how we handle personal data relating to a <strong>Tenant</strong>, meaning an end user
whose data the Customer manages within the Service.
</p>
</div>
<p>
We act in two distinct roles. We are the <strong>controller</strong> of the account and
profile data that relates to your use of the Service. We are a <strong>processor</strong>{" "}
of the property, unit, Tenant, lease, and financial records that the Customer enters,
because the Customer determines the purposes and means of that processing. Our processing
of that data on the Customer&rsquo;s behalf is governed by our{" "}
<a href="/dpa">Data Processing Addendum</a>.
</p>
</Section>
<Section heading="Information we collect">
<p>We collect the following categories of information:</p>
<ul>
<li>
<strong>Account and profile data</strong>&mdash;such as your name, email address,
password credentials, organization details, and preferences.
</li>
<li>
<strong>Portfolio data you enter</strong>&mdash;property, unit, Tenant, lease, rent,
maintenance, and expense records that the Customer creates or uploads to the Service.
</li>
<li>
<strong>Payment metadata</strong>&mdash;billing information processed through Stripe.
Stripe handles card details directly; we never receive or store full card numbers.
</li>
<li>
<strong>Usage and device or log data</strong>&mdash;such as IP address, browser type,
device information, pages accessed, and timestamps generated when you use the Service.
</li>
<li>
<strong>Cookies and similar technologies</strong>&mdash;as described in our{" "}
<a href="/cookie-policy">Cookie Policy</a>.
</li>
</ul>
</Section>
<Section heading="How we use information">
<p>We use personal data to:</p>
<ul>
<li>Provide, operate, maintain, and improve the Service;</li>
<li>Process billing, subscriptions, and payments through Stripe;</li>
<li>Protect the Service through security monitoring and fraud prevention;</li>
<li>Respond to support requests and communicate with you about your account;</li>
<li>
Generate optional AI insights when you choose to use AI features (described below); and
</li>
<li>Comply with legal obligations and enforce our agreements.</li>
</ul>
</Section>
<Section heading="Legal bases for processing">
<p>
Where the General Data Protection Regulation (GDPR) applies, we rely on the following legal
bases:
</p>
<ul>
<li>
<strong>Performance of a contract</strong>&mdash;to provide the Service you have signed
up for and to administer your account and billing.
</li>
<li>
<strong>Legitimate interests</strong>&mdash;to secure, maintain, and improve the Service
and to prevent fraud and abuse, provided such interests are not overridden by your
rights.
</li>
<li>
<strong>Consent</strong>&mdash;where you have given it, for example when you choose to use
optional features such as AI insights or optional sign-in providers.
</li>
<li>
<strong>Legal obligation</strong>&mdash;to comply with applicable laws, including the
retention of certain financial records.
</li>
</ul>
</Section>
<Section heading="AI processing">
<p>
When you choose to use AI features, the relevant portfolio data is sent to{" "}
<strong>OpenAI</strong> on a per-request basis in order to generate the requested insight.
That data is <strong>not used to train models</strong>. AI features are optional: if you do
not use them, no portfolio data is transmitted to OpenAI. See our{" "}
<a href="/subprocessors">sub-processors</a> page for further detail.
</p>
</Section>
<Section heading="How we share information">
<p>We share personal data only in the limited circumstances described below.</p>
<ul>
<li>
<strong>Service providers and sub-processors</strong>&mdash;we engage trusted vendors to
host, operate, and support the Service. Our current sub-processors include{" "}
{SUBPROCESSORS.map((sp, i) => (
<span key={sp.name}>
<strong>{sp.name}</strong>
{i < SUBPROCESSORS.length - 1 ? ", " : ""}
</span>
))}
. The full, current list is maintained on our{" "}
<a href="/subprocessors">sub-processors</a> page.
</li>
<li>
<strong>Legal and safety</strong>&mdash;where required to comply with law, respond to
lawful requests, or protect the rights, property, or safety of any person.
</li>
<li>
<strong>Business transfers</strong>&mdash;in connection with a merger, acquisition,
financing, or sale of assets, subject to this policy.
</li>
</ul>
<p>
We do <strong>not</strong> sell or rent personal data.
</p>
</Section>
<Section heading="International data transfers">
<p>
Personal data may be processed in countries other than the one in which it was collected,
including the United States. Where we transfer personal data across borders, we rely on
appropriate safeguards such as the <strong>Standard Contractual Clauses</strong> approved by
the European Commission, together with supplementary measures where required.
</p>
</Section>
<Section heading="Data retention">
<p>
We retain personal data for as long as your account remains active. Following account
deletion, we delete or anonymize personal data within{" "}
<strong>{LEGAL.dataDeletionDays} days</strong>, except where a longer retention period is
required by law or for legitimate business purposes such as the retention of financial and
tax records.
</p>
</Section>
<Section heading="Security">
<p>
We protect personal data using encryption in transit and at rest, and we enforce access
isolation at the <strong>application layer</strong>: every request is authenticated and
scoped to the relevant account, so that one account cannot access another account&rsquo;s
records. Uploaded files are stored privately and served only through authenticated,
per-account access. We also employ authentication controls and automated bot protection on
our sign-in forms. No method of transmission or storage is <strong>100% secure</strong>, and
we cannot guarantee absolute security.
</p>
</Section>
<Section heading="Your privacy rights">
<p>
Depending on where you live, you may have the following rights in relation to your personal
data.
</p>
<p>
<strong>Rights under the GDPR.</strong> If you are in the European Economic Area or the
United Kingdom, you may request:
</p>
<ul>
<li>Access to your personal data;</li>
<li>Rectification of inaccurate or incomplete data;</li>
<li>Erasure of your data;</li>
<li>Restriction of processing;</li>
<li>Portability of the data you have provided;</li>
<li>To object to certain processing;</li>
<li>To withdraw consent where processing is based on consent; and</li>
<li>To lodge a complaint with a supervisory authority.</li>
</ul>
<p>
<strong>Rights under California law (CCPA and CPRA).</strong> If you are a California
resident, you may request to know, delete, and correct the personal information we hold
about you, and to opt out of any sale or sharing of personal information. We do{" "}
<strong>not</strong> sell or share personal information for cross-context behavioral
advertising.
</p>
<p>
To exercise any of these rights, contact us at{" "}
<a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. We will respond within
the timeframe required by applicable law and may need to verify your identity before acting
on your request. For further detail on data rights, see our{" "}
<a href="/gdpr">GDPR and Data Rights</a> page.
</p>
</Section>
<Section heading="Notice to Tenants">
<p>
If you are a <strong>Tenant</strong>, your landlord or property manager (the{" "}
<strong>Customer</strong>) is the <strong>controller</strong> of the data held about you
within the Service, and we act as a <strong>processor</strong> on their behalf. Please
direct requests to access, correct, or delete your data to the Customer who manages your
tenancy. We will assist that Customer in responding, as set out in our{" "}
<a href="/dpa">Data Processing Addendum</a>.
</p>
</Section>
<Section heading="Children">
<p>
The Service is not directed to children, and we do not knowingly collect personal data from
children. If you believe a child has provided us with personal data, please contact us so
that we can delete it.
</p>
</Section>
<Section heading="Changes to this policy">
<p>
We may update this Privacy Policy from time to time. When we make material changes, we will
update the date shown above and, where appropriate, provide additional notice. Your
continued use of the Service after an update constitutes acceptance of the revised policy.
</p>
</Section>
<Callout>
This Privacy Policy should be read together with our <a href="/terms">Terms of Service</a>,{" "}
<a href="/cookie-policy">Cookie Policy</a>, and{" "}
<a href="/dpa">Data Processing Addendum</a>.
</Callout>
<LegalContact email={LEGAL.privacyEmail}>
{
"If you have questions about this policy or wish to exercise your privacy rights, contact us at "
}
</LegalContact>
<Section heading="Data protection contact">
<p>
For data-protection matters specifically, you may also contact our data protection team at{" "}
<a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>.
</p>
</Section>
</LegalPage>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
export const metadata = {
title: "Refund & Cancellation Policy",
description: `How subscriptions, cancellations, and refunds work for the ${LEGAL.service} platform.`,
alternates: { canonical: "/refund-policy" },
}
export default function Page() {
return (
<LegalPage
title="Refund & Cancellation Policy"
subtitle={`This policy explains how billing, cancellations, and refunds work for the ${LEGAL.service} platform. It forms part of our Terms of Service.`}
>
<Section heading="1. Overview">
<p>
This Refund &amp; Cancellation Policy (the <strong>Policy</strong>) describes how subscriptions to the{" "}
{LEGAL.service} platform (the <strong>Service</strong>), operated by {LEGAL.entity} (<strong>we</strong>,{" "}
<strong>us</strong>, or <strong>our</strong>), are billed, how you (the <strong>Customer</strong>) may
cancel, and when refunds are available. This Policy forms part of our <a href="/terms">Terms of
Service</a>. In the event of any conflict, the Terms of Service govern.
</p>
</Section>
<Section heading="2. Subscription billing">
<p>
The Service is offered under the <strong>Starter</strong> (free), <strong>Pro</strong> ($29 per month),{" "}
<strong>Landlord</strong> ($59 per month), and <strong>Lifetime</strong> ($199 one-time) plans. Monthly
recurring plans are billed in advance at the start of each billing cycle through our payment processor,{" "}
<strong>Stripe</strong>, and renew automatically until cancelled.
</p>
</Section>
<Section heading="3. Cancellation">
<p>
You may cancel a paid subscription at any time from your billing settings or through the Stripe customer
portal. When you cancel a monthly plan, your subscription remains active and you retain access until the end
of the billing period you have already paid for; it will not renew for the following period.
</p>
<p>
Because monthly plans are billed in advance, <strong>we do not provide partial-period refunds for the
unused portion of a monthly billing cycle</strong> unless required by applicable law or expressly granted by
us.
</p>
</Section>
<Section heading="4. Refunds">
<p>
Except as expressly stated in this Policy or required by applicable law, monthly subscription fees are
generally <strong>non-refundable</strong>. We may, at our sole discretion, grant a refund or credit in
individual cases, for example in the event of an extended service outage or a billing error.
</p>
<p>
To request a refund or raise a billing concern, contact us at{" "}
<a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a> with your account details and a
description of the issue, and we will review your request.
</p>
</Section>
<Section heading="5. Lifetime plan">
<p>
The <strong>Lifetime</strong> plan is a single, one-time charge rather than a recurring subscription. The
Lifetime plan is refundable if you request a refund within {LEGAL.lifetimeRefundDays} days of the date of
purchase. After the {LEGAL.lifetimeRefundDays}-day window has passed, the Lifetime plan is{" "}
<strong>non-refundable</strong>, except where a refund is required by applicable law.
</p>
</Section>
<Section heading="6. Failed payments and past-due accounts">
<p>
If a scheduled payment fails, we and Stripe may attempt to charge your payment method again over a short
period (a process known as dunning). If payment remains unsuccessful and your account becomes past due, we
may downgrade your account to the free plan, suspend paid features, or ultimately suspend access to the
Service until the outstanding amount is paid.
</p>
</Section>
<Section heading="7. Price changes">
<p>
We may change the fees for a plan from time to time. If a price change affects your subscription, we will
provide advance notice, and the new price will take effect on your next billing cycle. If you do not wish to
accept a price change, you may cancel before it takes effect.
</p>
</Section>
<Section heading="8. Chargebacks">
<p>
If you believe you have been charged in error, please contact us at{" "}
<a href={`mailto:${LEGAL.contactEmail}`}>{LEGAL.contactEmail}</a> before initiating a chargeback with your
bank or card issuer, so that we can resolve the matter quickly. Initiating a chargeback or payment dispute
without first contacting us may lead to suspension or termination of your account while the dispute is
investigated.
</p>
</Section>
<Section heading="9. Statutory consumer rights">
<p>
If you are a consumer in the European Union, the United Kingdom, or another jurisdiction that grants a
statutory right of withdrawal or cancellation, you may have rights that are additional to those described in
this Policy. <strong>Nothing in this Policy limits or overrides any non-waivable statutory rights you may
have as a consumer under applicable law.</strong>
</p>
</Section>
<Callout>
<strong>In short:</strong> you may cancel a monthly plan at any time and keep access until the end of the
period you have paid for, monthly fees are otherwise non-refundable, and the Lifetime plan is refundable
within {LEGAL.lifetimeRefundDays} days of purchase.
</Callout>
<LegalContact email={LEGAL.legalEmail} />
</LegalPage>
)
}
-118
View File
@@ -1,118 +0,0 @@
export const metadata = {
title: "System Status — Property Management Network",
description: "Real-time status of all Property Management Network services.",
}
const SERVICES = [
{ name: "Web Application", status: "operational", uptime: "99.98%" },
{ name: "API Gateway", status: "operational", uptime: "99.97%" },
{ name: "Database (Supabase)", status: "operational", uptime: "99.99%" },
{ name: "Payment Processing (Stripe)", status: "operational", uptime: "99.95%" },
{ name: "Email Delivery (Resend)", status: "operational", uptime: "99.96%" },
{ name: "File Storage", status: "operational", uptime: "99.99%" },
{ name: "Tenant Portal", status: "operational", uptime: "99.97%" },
{ name: "Webhook Delivery", status: "operational", uptime: "99.90%" },
]
const INCIDENTS = [
{
date: "2026-03-28",
title: "Resolved: Delayed email notifications",
detail: "Email notifications were delayed by up to 12 minutes due to a Resend upstream issue. Fully resolved at 14:32 UTC.",
severity: "minor",
},
{
date: "2026-02-14",
title: "Resolved: Slow dashboard load times",
detail: "Database query optimisations were deployed to fix a slow index scan affecting dashboards with 50+ units. Resolved in 45 minutes.",
severity: "minor",
},
]
export default function StatusPage() {
const allOperational = SERVICES.every((s) => s.status === "operational")
return (
<div className="bg-[#09090b] text-white min-h-screen">
<div className="mx-auto max-w-3xl px-6 pt-32 pb-24">
{/* Header */}
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">System Status</h1>
<p className="text-white/40 text-sm">Real-time health of all Property Management Network services.</p>
</div>
{/* Overall status */}
<div className={`flex items-center gap-3 rounded-2xl border p-5 mb-8 ${allOperational ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}`}>
<div className={`relative flex h-3 w-3`}>
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${allOperational ? "bg-emerald-400" : "bg-red-400"}`} />
<span className={`relative inline-flex rounded-full h-3 w-3 ${allOperational ? "bg-emerald-400" : "bg-red-400"}`} />
</div>
<div>
<p className={`font-semibold text-sm ${allOperational ? "text-emerald-300" : "text-red-300"}`}>
{allOperational ? "All systems operational" : "Partial outage detected"}
</p>
<p className="text-xs text-white/30 mt-0.5">Last checked: just now</p>
</div>
</div>
{/* Services */}
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden mb-10">
<div className="px-5 py-3.5 border-b border-white/[0.04]">
<p className="text-xs font-semibold uppercase tracking-wider text-white/30">Services</p>
</div>
{SERVICES.map((svc, i) => (
<div key={svc.name} className={`flex items-center justify-between px-4 sm:px-5 py-3.5 sm:py-4 gap-3 ${i !== SERVICES.length - 1 ? "border-b border-white/[0.04]" : ""}`}>
<div className="flex items-center gap-2 min-w-0">
<div className={`shrink-0 h-2 w-2 rounded-full ${svc.status === "operational" ? "bg-emerald-400" : svc.status === "degraded" ? "bg-amber-400" : "bg-red-400"}`} />
<span className="text-sm text-white/80 truncate">{svc.name}</span>
</div>
<div className="flex items-center gap-3 sm:gap-6 shrink-0">
<span className="hidden sm:block text-xs text-white/30 tabular-nums">{svc.uptime} uptime</span>
<span className={`text-xs font-medium capitalize ${svc.status === "operational" ? "text-emerald-400" : svc.status === "degraded" ? "text-amber-400" : "text-red-400"}`}>
{svc.status}
</span>
</div>
</div>
))}
</div>
{/* Uptime graph placeholder */}
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] p-5 mb-10">
<p className="text-xs font-semibold uppercase tracking-wider text-white/30 mb-4">90-day uptime</p>
<div className="flex gap-px sm:gap-0.5 h-6 sm:h-8 items-end">
{Array.from({ length: 90 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-sm bg-emerald-500/70"
style={{ height: `${Math.random() > 0.03 ? 100 : Math.floor(Math.random() * 60 + 20)}%` }}
/>
))}
</div>
<div className="flex justify-between mt-2 text-[10px] text-white/20">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
{/* Incidents */}
<div>
<h2 className="text-lg font-bold text-white mb-4">Past Incidents</h2>
<div className="space-y-3">
{INCIDENTS.map((inc) => (
<div key={inc.date} className="rounded-2xl border border-white/[0.06] bg-[#111118] p-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-400 uppercase">
{inc.severity}
</span>
<span className="text-xs text-white/30">{inc.date}</span>
</div>
<p className="text-sm font-semibold text-white mb-1">{inc.title}</p>
<p className="text-xs text-white/50 leading-relaxed">{inc.detail}</p>
</div>
))}
</div>
</div>
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
export const metadata = {
title: "Sub-processors",
description: `The third-party sub-processors that ${LEGAL.entity} engages to process personal data on behalf of Customers of ${LEGAL.service}.`,
alternates: { canonical: "/subprocessors" },
}
export default function SubprocessorsPage() {
return (
<LegalPage
title="Sub-processors"
subtitle={`The third parties that ${LEGAL.entity} engages to process personal data on behalf of Customers of ${LEGAL.service}.`}
>
<Section id="overview" heading="1. Overview">
<p>
A <strong>sub-processor</strong> is a third party that we engage to process
personal data on behalf of the Customer in connection with the Service. When the
Customer uses {LEGAL.service}, the Customer grants us a general authorization to
engage the sub-processors listed below under the terms of our{" "}
<Link href="/dpa">Data Processing Addendum</Link>. Each sub-processor is bound by
data-protection obligations that are substantially equivalent to those we owe the
Customer.
</p>
</Section>
<Section id="current" heading="2. Current sub-processors">
<p>
The following third parties are the sub-processors currently engaged to process
personal data on the Customer&rsquo;s behalf:
</p>
<div className="rounded-2xl border border-white/[0.06] bg-[#111118] overflow-hidden">
<div className="hidden sm:grid grid-cols-[1.2fr_2fr_1.4fr] gap-4 border-b border-white/[0.08] px-5 py-3 text-xs font-semibold uppercase tracking-wider text-white/40">
<span>Sub-processor</span>
<span>Purpose</span>
<span>Location</span>
</div>
{SUBPROCESSORS.map((sp, i) => (
<div
key={sp.name}
className={`grid grid-cols-1 gap-1 px-5 py-4 text-sm sm:grid-cols-[1.2fr_2fr_1.4fr] sm:gap-4 ${
i !== SUBPROCESSORS.length - 1 ? "border-b border-white/[0.04]" : ""
}`}
>
<span className="text-white/80 font-medium">{sp.name}</span>
<span className="text-white/50">{sp.purpose}</span>
<span className="text-white/50">{sp.location}</span>
</div>
))}
</div>
</Section>
<Section id="changes" heading="3. Changes to this list">
<p>
We update this page whenever our sub-processors change. The Customer may request
to be notified of additions to, or replacements of, our sub-processors, and may
object to a change on legitimate data-protection grounds, as described in our{" "}
<Link href="/dpa">Data Processing Addendum</Link>.
</p>
</Section>
<LegalContact email={LEGAL.dpoEmail} />
</LegalPage>
)
}
+4 -3
View File
@@ -2,15 +2,16 @@ import Link from "next/link"
import { Building2, CheckCircle2, Smartphone, Bell, FileText, ArrowRight } from "lucide-react"
export const metadata = {
title: "Tenant Portal — Property Management Network",
title: "Tenant Portal",
description: "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
alternates: { canonical: "/tenant-portal-info" },
}
const FEATURES = [
{ icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", title: "Online Rent Payment", desc: "Tenants pay rent securely via Stripe — card or bank transfer. Auto-receipts sent by email." },
{ icon: FileText, color: "text-indigo-400", bg: "bg-indigo-500/10 border-indigo-500/20", title: "Lease Documents", desc: "View and download lease agreements, addendums, and move-in checklists anytime." },
{ icon: Building2, color: "text-violet-400", bg: "bg-violet-500/10 border-violet-500/20", title: "Maintenance Requests", desc: "Submit maintenance issues with photos. Track status from open → in progress → resolved." },
{ icon: Bell, color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20", title: "Smart Notifications", desc: "Rent due reminders, request updates, and landlord messages via email or WhatsApp." },
{ icon: Bell, color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20", title: "Smart Notifications", desc: "Rent due reminders, request updates, and landlord messages delivered by email." },
{ icon: Smartphone, color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20", title: "Mobile Friendly", desc: "Works perfectly on any device — no app download needed, just a secure link." },
{ icon: FileText, color: "text-rose-400", bg: "bg-rose-500/10 border-rose-500/20", title: "Payment History", desc: "Full history of all payments and receipts. Great for tenant records and disputes." },
]
@@ -98,7 +99,7 @@ export default function TenantPortalInfoPage() {
<div className="border-t border-white/[0.06] py-20">
<div className="mx-auto max-w-2xl px-6 text-center">
<h2 className="text-2xl font-bold text-white mb-4">Ready to give tenants a better experience?</h2>
<p className="text-white/40 mb-8">Free plan includes up to 2 properties and unlimited tenant portal access.</p>
<p className="text-white/40 mb-8">Free plan includes 1 property and unlimited tenant portal access.</p>
<Link
href="/signup"
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-8 py-3.5 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-lg hover:shadow-indigo-500/25"
+303 -12
View File
@@ -1,17 +1,308 @@
export const metadata = { title: "Terms of Service — Property Management Network" }
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
export default function TermsPage() {
export const metadata = {
title: "Terms of Service",
description: `The terms and conditions that govern your access to and use of the ${LEGAL.service} platform.`,
alternates: { canonical: "/terms" },
}
export default function Page() {
return (
<div className="mx-auto max-w-3xl px-6 py-24">
<h1 className="text-3xl font-bold text-white mb-8">Terms of Service</h1>
<p className="text-white/50 text-sm leading-relaxed">
By using Property Management Network you agree to these terms. Property Management Network is provided as-is for property
management purposes. You are responsible for the accuracy of data you enter. Subscription fees
are billed monthly or as a one-time charge through Stripe. You may cancel at any time
cancellation takes effect at the end of your billing period. Lifetime plans are non-refundable
after 14 days. We reserve the right to suspend accounts that violate these terms.
For questions, contact us at support@propertymanagement.network.
<LegalPage
title="Terms of Service"
subtitle={`These Terms of Service govern your access to and use of the ${LEGAL.service} platform. Please read them carefully, because they form a binding agreement between you and ${LEGAL.entity}.`}
>
<Section heading="1. Agreement to these Terms">
<p>
These Terms of Service (the <strong>Terms</strong>) form a legally binding agreement between you and{" "}
{LEGAL.entity} (<strong>we</strong>, <strong>us</strong>, or <strong>our</strong>) and govern your access
to and use of the {LEGAL.service} platform, together with all related websites, applications, features,
and services (collectively, the <strong>Service</strong>). By creating an account, accessing, or using the
Service, you agree to be bound by these Terms. If you do not agree, you must not access or use the Service.
</p>
</div>
<p>
You represent that you are at least 18 years of age (or the age of legal majority in your jurisdiction) and
are capable of forming a binding contract. If you use the Service on behalf of a company, organization, or
other legal entity, you represent and warrant that you have the authority to bind that entity to these
Terms, and in that case <strong>you</strong> and <strong>Customer</strong> refer to that entity.
</p>
<p>
Throughout these Terms, <strong>you</strong> or <strong>Customer</strong> means the account holder (a
landlord or property manager); <strong>Tenant</strong> means an end user whose data the Customer manages;
and <strong>Customer Content</strong> means the data the Customer enters into or uploads to the Service.
</p>
</Section>
<Section heading="2. Description of the Service">
<p>
The Service is a software-as-a-service platform that helps landlords and property managers manage rental
properties, including features for organizing properties and units, tracking tenancies and leases,
recording payments and expenses, storing documents, sending notifications, generating AI-assisted
insights, and sharing information with Tenants through a token-based Tenant portal.
</p>
<p>
We may add, modify, or discontinue features of the Service from time to time. The Service is provided as an
online tool and is not a substitute for professional legal, financial, tax, accounting, or
property-management advice.
</p>
</Section>
<Section heading="3. Accounts and registration">
<p>
To use most features of the Service, you must create an account. You agree to provide accurate, current,
and complete information during registration and to keep that information up to date. You may register
using an email address and password or, where offered, through optional Google sign-in.
</p>
<p>
You are responsible for safeguarding your account credentials and for restricting access to your account.
You must keep your password confidential and notify us promptly at{" "}
<a href={`mailto:${LEGAL.securityEmail}`}>{LEGAL.securityEmail}</a> if you suspect any unauthorized use of
your account. You are responsible for all activity that occurs under your account, whether or not
authorized by you, to the extent permitted by applicable law.
</p>
</Section>
<Section heading="4. Subscriptions, plans, and billing">
<p>
The Service is offered under several plans: <strong>Starter</strong> (free), <strong>Pro</strong> ($29 per
month), <strong>Landlord</strong> ($59 per month), and <strong>Lifetime</strong> ($199 as a one-time
charge). The features and limits associated with each plan are described on our pricing page and may be
updated from time to time.
</p>
<p>
Paid subscriptions are billed in advance through our payment processor, <strong>Stripe</strong>. By
providing a payment method, you authorize us and Stripe to charge the applicable fees, together with any
applicable taxes, to that payment method. Monthly subscriptions renew automatically at the end of each
billing cycle until cancelled. You are responsible for all taxes, duties, and similar charges associated
with your use of the Service, other than taxes based on our net income.
</p>
<p>
We may change our fees or introduce new charges. If we increase the fees for a plan you are subscribed to,
we will provide prior notice, and the change will take effect on your next billing cycle. Your continued
use of the Service after a price change takes effect constitutes acceptance of the new fees. Cancellations
and refunds are governed by our <a href="/refund-policy">Refund &amp; Cancellation Policy</a>.
</p>
</Section>
<Section heading="5. Customer Content and responsibilities">
<p>
As between you and us, you retain all rights in and ownership of your <strong>Customer Content</strong>. You
grant us a limited, non-exclusive, worldwide license to host, store, process, transmit, display, and
otherwise use the Customer Content solely as necessary to provide, secure, and improve the Service and to
comply with your instructions and applicable law.
</p>
<p>
You are solely responsible for the accuracy, quality, and legality of your Customer Content and for the
means by which you acquired it. Where you enter or upload personal data relating to a{" "}
<strong>Tenant</strong> or any other individual, you represent and warrant that you have a lawful basis and,
where required, the necessary consent to do so, and that your use of the Service complies with all
applicable data-protection, housing, and landlord-tenant laws.
</p>
<p>
For personal data that you process through the Service, you act as the <strong>data controller</strong> and
we act as your processor. Our respective obligations are set out in our{" "}
<a href="/dpa">Data Processing Addendum</a>, and our general data practices are described in our{" "}
<a href="/privacy">Privacy Policy</a>.
</p>
</Section>
<Section heading="6. Acceptable use">
<p>
Your use of the Service must comply with our <a href="/acceptable-use">Acceptable Use Policy</a>, which is
incorporated into these Terms by reference. Among other things, you must not use the Service for any
unlawful purpose, to violate housing, fair-housing, anti-discrimination, or landlord-tenant laws, to harass
Tenants, or to compromise the security or integrity of the Service. A violation of the Acceptable Use Policy
is a violation of these Terms.
</p>
</Section>
<Section heading="7. Tenant portal">
<p>
The Service includes a token-based <strong>Tenant portal</strong> that allows you to share selected
information with a Tenant through a secure link. You control what information is shared and with whom, and
you are responsible for distributing token links only to the intended recipients and for revoking access
when it is no longer appropriate. We are not responsible for information disclosed as a result of your
sharing decisions or your handling of token links.
</p>
</Section>
<Section heading="8. Intellectual property">
<p>
The Service, including all software, designs, text, graphics, and other materials that we provide (but
excluding Customer Content), is owned by us or our licensors and is protected by intellectual-property
laws. Subject to your compliance with these Terms, we grant you a limited, revocable, non-exclusive,
non-transferable license to access and use the Service for your internal business purposes during the term
of your subscription. All rights not expressly granted are reserved.
</p>
<p>
If you choose to provide us with suggestions, ideas, or other feedback about the Service, you grant us a
perpetual, irrevocable, worldwide, royalty-free license to use and incorporate that feedback into the
Service without any obligation or compensation to you.
</p>
</Section>
<Section heading="9. Third-party services">
<p>
The Service relies on and integrates with third-party services, including <strong>Stripe</strong> for
payments, <strong>OpenAI</strong> for AI features, <strong>SMTP2GO</strong> for email delivery,{" "}
<strong>DigitalOcean</strong> for hosting and storage, and optional <strong>Google</strong> sign-in. Your
use of those services may be subject to their own terms and policies. We do not control and are not
responsible for third-party services, and we make no warranties regarding them. A current list of the
third parties that process personal data on our behalf is available on our{" "}
<a href="/subprocessors">Sub-processors</a> page.
</p>
</Section>
<Section heading="10. AI features">
<p>
Certain features of the Service use artificial-intelligence models (provided by OpenAI) to generate
insights, summaries, and suggestions. <strong>AI-generated outputs may be inaccurate, incomplete, or
otherwise unreliable, are provided for informational purposes only, and do not constitute legal, financial,
tax, or professional advice.</strong> You are responsible for reviewing and independently verifying any
AI-generated output before relying on it. Please review our <a href="/disclaimer">Disclaimer</a> for
further information.
</p>
</Section>
<Section heading="11. Privacy">
<p>
Our collection and use of personal information in connection with the Service is described in our{" "}
<a href="/privacy">Privacy Policy</a>. By using the Service, you acknowledge that you have read and
understood our Privacy Policy.
</p>
</Section>
<Section heading="12. Suspension and termination">
<p>
You may cancel your subscription or close your account at any time as described in our{" "}
<a href="/refund-policy">Refund &amp; Cancellation Policy</a>. We may suspend or terminate your access to
the Service, in whole or in part, if you breach these Terms, if your use poses a security, legal, or
operational risk, if required by law, or if you fail to pay fees when due.
</p>
<p>
Upon termination, your right to access and use the Service ceases. For a period of{" "}
{LEGAL.dataDeletionDays} days following termination, and where technically feasible, you may request an
export of your Customer Content, after which we may delete or de-identify it in accordance with our{" "}
<a href="/privacy">Privacy Policy</a> and <a href="/dpa">Data Processing Addendum</a>. Provisions that by
their nature should survive termination will survive.
</p>
</Section>
<Section heading="13. Disclaimer of warranties">
<p>
<strong>
THE SERVICE IS PROVIDED ON AN &ldquo;AS IS&rdquo; AND &ldquo;AS AVAILABLE&rdquo; BASIS, WITHOUT
WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, OR STATUTORY.
</strong>{" "}
To the maximum extent permitted by applicable law, we disclaim all warranties, including any implied
warranties of merchantability, fitness for a particular purpose, title, and non-infringement, and any
warranties arising out of course of dealing or usage of trade.
</p>
<p>
<strong>
We do not warrant that the Service will be uninterrupted, secure, error-free, or free of harmful
components, or that any AI-generated output or other results obtained through the Service will be accurate
or reliable.
</strong>{" "}
Some jurisdictions do not allow the exclusion of certain warranties, so some of the above exclusions may not
apply to you.
</p>
</Section>
<Section heading="14. Limitation of liability">
<p>
<strong>
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL WE BE LIABLE FOR ANY INDIRECT,
INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE,
DATA, GOODWILL, OR BUSINESS OPPORTUNITIES, ARISING OUT OF OR RELATING TO THESE TERMS OR THE SERVICE,
WHETHER BASED ON CONTRACT, TORT, OR ANY OTHER LEGAL THEORY, EVEN IF WE HAVE BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
</strong>
</p>
<p>
<strong>
OUR TOTAL AGGREGATE LIABILITY ARISING OUT OF OR RELATING TO THESE TERMS OR THE SERVICE WILL NOT EXCEED THE
GREATER OF (A) THE TOTAL FEES YOU PAID TO US FOR THE SERVICE IN THE TWELVE (12) MONTHS IMMEDIATELY
PRECEDING THE EVENT GIVING RISE TO THE CLAIM, OR (B) ONE HUNDRED U.S. DOLLARS (USD 100).
</strong>{" "}
Some jurisdictions do not allow the limitation or exclusion of liability for certain damages, so some of the
above limitations may not apply to you.
</p>
</Section>
<Section heading="15. Indemnification">
<p>
You agree to defend, indemnify, and hold harmless {LEGAL.entity} and its officers, directors, employees,
and agents from and against any claims, liabilities, damages, losses, and expenses (including reasonable
legal fees) arising out of or relating to: (a) your Customer Content; (b) your use of the Service; (c) your
violation of these Terms or applicable law; and (d) any claim brought by a Tenant or other third party
arising out of your use of the Service or your handling of their data.
</p>
</Section>
<Section heading="16. Changes to the Service and to these Terms">
<p>
We may modify, update, or discontinue the Service or any part of it at any time. We may also revise these
Terms from time to time. When we make material changes, we will provide notice by reasonable means, such as
by posting the updated Terms with a new effective date or by notifying you through the Service or by email.
Your continued use of the Service after the changes take effect constitutes acceptance of the revised
Terms.
</p>
</Section>
<Section heading="17. Governing law and dispute resolution">
<p>
These Terms and any dispute arising out of or relating to them or the Service are governed by the laws of{" "}
{LEGAL.governingLaw}, without regard to its conflict-of-laws principles. Subject to the arbitration
provision below, the parties submit to the exclusive jurisdiction and venue of {LEGAL.forum}.
</p>
<p>
<strong>Informal resolution.</strong> Before initiating any formal proceeding, you agree to first contact
us at <a href={`mailto:${LEGAL.legalEmail}`}>{LEGAL.legalEmail}</a> and to attempt in good faith to resolve
the dispute informally. Most concerns can be resolved this way.
</p>
<p>
<strong>Binding arbitration and class-action waiver.</strong> To the fullest extent permitted by applicable
law, any dispute that is not resolved informally will be settled by final and binding arbitration on an
individual basis, rather than in court, except that either party may bring an individual claim in
small-claims court. <strong>YOU AND WE EACH WAIVE ANY RIGHT TO A JURY TRIAL AND TO PARTICIPATE IN A CLASS,
COLLECTIVE, OR REPRESENTATIVE ACTION.</strong> Arbitration will be conducted by a recognized arbitration
body under its applicable rules, and judgment on the award may be entered in any court of competent
jurisdiction.
</p>
<p>
Nothing in this section limits any statutory rights that cannot be waived under the law that applies to you.
Consumers in certain jurisdictions may have non-waivable rights to bring claims in their local courts or
before their local authorities, and this section does not override those rights.
</p>
</Section>
<Section heading="18. Miscellaneous">
<p>
These Terms, together with the policies incorporated by reference (including the{" "}
<a href="/acceptable-use">Acceptable Use Policy</a>, <a href="/privacy">Privacy Policy</a>,{" "}
<a href="/dpa">Data Processing Addendum</a>, and <a href="/refund-policy">Refund &amp; Cancellation
Policy</a>), constitute the entire agreement between you and us regarding the Service and supersede all
prior agreements on that subject.
</p>
<p>
If any provision of these Terms is held to be invalid or unenforceable, that provision will be limited or
eliminated to the minimum extent necessary, and the remaining provisions will remain in full force and
effect. You may not assign or transfer these Terms without our prior written consent; we may assign these
Terms in connection with a merger, acquisition, or sale of assets. Our failure to enforce any provision is
not a waiver of our right to do so later.
</p>
<p>
We will not be liable for any failure or delay in performance caused by events beyond our reasonable
control, including acts of God, natural disasters, outages, or the failure of third-party services (a{" "}
<strong>force majeure</strong> event). Notices to you may be given through the Service or by email to the
address associated with your account; notices to us must be sent to{" "}
<a href={`mailto:${LEGAL.legalEmail}`}>{LEGAL.legalEmail}</a>.
</p>
</Section>
<LegalContact email={LEGAL.legalEmail} />
</LegalPage>
)
}
+30
View File
@@ -0,0 +1,30 @@
"use server"
import { revalidatePath } from "next/cache"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { disconnect, syncNow, getProvider, type Provider } from "@/lib/accounting"
async function ownerGuard() {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) throw new Error("Only the account owner can manage integrations")
return ctx
}
export async function disconnectAccounting(provider: string) {
const ctx = await ownerGuard()
if (!getProvider(provider)) throw new Error("Unknown provider")
await disconnect(ctx.ownerId, provider as Provider)
revalidatePath("/settings/integrations")
return { ok: true }
}
export async function syncAccountingNow(provider: string) {
const ctx = await ownerGuard()
if (!getProvider(provider)) throw new Error("Unknown provider")
const result = await syncNow(ctx.ownerId, provider as Provider)
revalidatePath("/settings/integrations")
return { ok: true, ...result }
}
+21
View File
@@ -7,6 +7,7 @@ import { eq } from "drizzle-orm"
import { z } from "zod"
import { getAdminSession } from "@/lib/session"
import { logAdminAction } from "@/lib/admin/audit"
import { setMaintenanceMode } from "@/lib/settings"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { profiles, user as userTable } from "@/lib/db/schema"
@@ -137,3 +138,23 @@ export async function markEmailVerified(userId: string) {
revalidatePath(`/admin/users/${userId}`)
return { ok: true }
}
// ── site maintenance mode ─────────────────────────────────────────────────────
// Toggles the site-wide maintenance flag (persisted in app_settings). When on,
// the marketing site and dashboard show a maintenance page to everyone except
// admins. Revalidates the whole app so the change takes effect immediately.
export async function setSiteMaintenance(enabled: boolean, message?: string) {
const a = await guard()
const trimmed = message?.trim() || null
await setMaintenanceMode({ enabled: Boolean(enabled), message: trimmed })
await logAdminAction({
adminId: a.user.id,
action: "maintenance_mode",
metadata: { enabled: Boolean(enabled), message: trimmed },
})
revalidatePath("/", "layout")
return { ok: true }
}
+63
View File
@@ -0,0 +1,63 @@
"use server"
import { revalidatePath } from "next/cache"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { api_keys } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { generateApiKey } from "@/lib/api-auth"
// ============================================================================
// API key management (dashboard, session-authed — NOT api-key authed).
//
// We store ONLY the SHA-256 hash of each key; the plaintext is returned exactly
// once from createApiKey and is never persisted. Every query is scoped to the
// session user's id so one user can never touch another user's keys.
// ============================================================================
const SETTINGS_PATH = "/settings/api-keys"
/**
* Create a new API key for the signed-in user. Returns the one-time plaintext
* (show it once, then it's gone) plus the non-secret display prefix.
*/
export async function createApiKey(
name: string
): Promise<{ plaintext: string; prefix: string }> {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const trimmed = typeof name === "string" ? name.trim() : ""
if (!trimmed) throw new Error("Key name is required")
if (trimmed.length > 100) throw new Error("Key name must be 100 characters or fewer")
const { plaintext, hash, prefix } = generateApiKey()
await db.insert(api_keys).values({
user_id: user.id,
name: trimmed,
key_hash: hash,
key_prefix: prefix,
})
revalidatePath(SETTINGS_PATH)
return { plaintext, prefix }
}
/**
* Revoke one of the signed-in user's keys. Scoped by user_id so a user can
* never revoke another user's key. Idempotent: re-revoking is a no-op.
*/
export async function revokeApiKey(id: string): Promise<void> {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
if (typeof id !== "string" || !id) throw new Error("Invalid key id")
await db
.update(api_keys)
.set({ revoked_at: new Date().toISOString() })
.where(and(eq(api_keys.id, id), eq(api_keys.user_id, user.id)))
revalidatePath(SETTINGS_PATH)
}
+41 -6
View File
@@ -4,42 +4,71 @@ import { redirect } from "next/navigation"
import { headers } from "next/headers"
import { APIError } from "better-auth/api"
import { auth } from "@/lib/auth"
import { verifyTurnstile } from "@/lib/turnstile"
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
const CAPTCHA_ERROR = "Please complete the verification challenge and try again."
/** Post-auth destination — only same-site relative paths (blocks open redirects). */
function safeNext(formData: FormData): string {
const next = formData.get("next")
if (typeof next === "string" && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\")) {
return next
}
return "/dashboard"
}
export async function signUp(formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
const fullName = formData.get("full_name") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
const h = await headers()
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(`/signup?error=${encodeURIComponent(CAPTCHA_ERROR)}`)
}
try {
await auth.api.signUpEmail({
body: { email, password, name: fullName },
headers: await headers(),
// callbackURL is where the verification link lands the user after confirming.
body: { email, password, name: fullName, callbackURL: "/dashboard" },
headers: h,
})
} catch (e) {
const msg = e instanceof APIError ? e.message : "Sign up failed"
redirect(`/signup?error=${encodeURIComponent(msg)}`)
}
redirect("/dashboard")
// When email verification is required, the account isn't usable until confirmed —
// send the user to the "check your email" screen instead of the dashboard.
if (process.env.REQUIRE_EMAIL_VERIFICATION === "true") {
redirect("/signup?success=check-email")
}
redirect(safeNext(formData))
}
export async function signIn(formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
const h = await headers()
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(`/login?error=${encodeURIComponent(CAPTCHA_ERROR)}`)
}
try {
await auth.api.signInEmail({
body: { email, password },
headers: await headers(),
headers: h,
})
} catch (e) {
const msg = e instanceof APIError ? e.message : "Invalid email or password"
redirect(`/login?error=${encodeURIComponent(msg)}`)
}
redirect("/dashboard")
redirect(safeNext(formData))
}
export async function signInWithGoogle() {
@@ -61,11 +90,17 @@ export async function signInWithGoogle() {
export async function resetPassword(formData: FormData) {
const email = formData.get("email") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
const h = await headers()
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(`/forgot-password?error=${encodeURIComponent(CAPTCHA_ERROR)}`)
}
try {
await auth.api.requestPasswordReset({
body: { email, redirectTo: `${APP_URL}/update-password` },
headers: await headers(),
headers: h,
})
} catch {
// Always report success so we don't reveal whether an account exists.
+60
View File
@@ -0,0 +1,60 @@
"use server"
import { redirect } from "next/navigation"
import { revalidatePath } from "next/cache"
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 { normalizeHexColor } from "@/lib/branding"
import type { Plan } from "@/types"
/**
* Saves the current user's white-label branding (brand name, logo, accent color,
* "Powered by" toggle). Only landlord/lifetime plans may edit branding — everyone
* else is redirected to billing. Values are validated before persisting.
*/
export async function updateBranding(formData: FormData) {
const user = await getSessionUser()
if (!user) redirect("/login")
// Plan gate: white-label is landlord/lifetime only.
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { plan: true },
})
const plan = (profile?.plan ?? "starter") as Plan
if (!PLAN_LIMITS[plan]?.hasWhiteLabel) {
redirect("/settings/billing")
}
// ── Validate inputs ──────────────────────────────────────────────────────
const rawName = (formData.get("brand_name") as string | null)?.trim() ?? ""
const brandName = rawName ? rawName.slice(0, 60) : null
const rawLogo = (formData.get("brand_logo_url") as string | null)?.trim() ?? ""
// Only accept logo URLs served by our own gated files route.
const brandLogoUrl = rawLogo && rawLogo.startsWith("/api/files/") ? rawLogo : null
const rawColor = (formData.get("brand_color") as string | null) ?? ""
const brandColor = normalizeHexColor(rawColor)
// If a color was supplied but is malformed, reject rather than silently drop it.
if (rawColor.trim() && !brandColor) {
throw new Error("Accent color must be a hex value like #4f46e5")
}
const hidePoweredBy = formData.get("hide_powered_by") === "on"
await db
.update(profiles)
.set({
brand_name: brandName,
brand_logo_url: brandLogoUrl,
brand_color: brandColor,
hide_powered_by: hidePoweredBy,
})
.where(eq(profiles.id, user.id))
revalidatePath("/settings/branding")
}
+17
View File
@@ -0,0 +1,17 @@
"use server"
import { revalidatePath } from "next/cache"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { sendLeaseForSignature, getAdapter, type ESignProvider } from "@/lib/esign"
export async function sendLeaseForSignatureAction(leaseId: string, provider: string) {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) throw new Error("You don't have permission to do that")
if (!getAdapter(provider)) throw new Error("Unknown provider")
await sendLeaseForSignature(ctx.ownerId, leaseId, provider as ESignProvider)
revalidatePath(`/leases/${leaseId}`)
return { ok: true }
}
+15
View File
@@ -0,0 +1,15 @@
"use server"
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"
/** Marks onboarding as finished and sends the user to the dashboard. */
export async function completeOnboarding() {
const user = await getSessionUser()
if (!user) redirect("/login")
await db.update(profiles).set({ onboarding_completed: true }).where(eq(profiles.id, user.id))
redirect("/dashboard")
}
+4 -7
View File
@@ -13,14 +13,13 @@ import {
expenses,
profiles,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getSessionUser, isAdminUser } from "@/lib/session"
import { redirect } from "next/navigation"
export async function seedDemoData() {
if (process.env.NODE_ENV === "production") { throw new Error("Demo tools are disabled in production") }
const user = await getSessionUser()
if (!user) redirect("/login")
if (!isAdminUser(user)) throw new Error("Admin only")
const uid = user.id
@@ -370,10 +369,9 @@ export async function seedDemoData() {
}
export async function setTestPlan(plan: "pro" | "landlord" | "lifetime" | "starter") {
if (process.env.NODE_ENV === "production") { throw new Error("Demo tools are disabled in production") }
const user = await getSessionUser()
if (!user) redirect("/login")
if (!isAdminUser(user)) throw new Error("Admin only")
await db.update(profiles).set({ plan }).where(eq(profiles.id, user.id))
@@ -381,10 +379,9 @@ export async function setTestPlan(plan: "pro" | "landlord" | "lifetime" | "start
}
export async function clearDemoData() {
if (process.env.NODE_ENV === "production") { throw new Error("Demo tools are disabled in production") }
const user = await getSessionUser()
if (!user) redirect("/login")
if (!isAdminUser(user)) throw new Error("Admin only")
const uid = user.id
+84
View File
@@ -0,0 +1,84 @@
"use server"
import { and, eq, ne } from "drizzle-orm"
import { db } from "@/lib/db"
import { account_members } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
export type AcceptInviteResult =
| { ok: true }
| { ok: false; error: string }
/**
* Accept a team invite by token.
*
* Requires an authenticated session (the page redirects to /login first).
* On success sets member_id = current user, status='active', accepted_at=now.
* A user can be an active member of at most one account, so we block if the
* caller is already active somewhere else. Handles invalid / already-used /
* revoked tokens gracefully.
*/
export async function acceptInvite(token: string): Promise<AcceptInviteResult> {
const user = await getSessionUser()
if (!user) return { ok: false, error: "You must be signed in to accept an invite." }
const invite = await db.query.account_members.findFirst({
where: eq(account_members.invite_token, token),
})
if (!invite) {
return { ok: false, error: "This invite link is invalid or has expired." }
}
if (invite.status === "revoked") {
return { ok: false, error: "This invite has been revoked by the account owner." }
}
// Already accepted — by this user (fine, treat as success) or someone else.
if (invite.status === "active") {
if (invite.member_id === user.id) return { ok: true }
return { ok: false, error: "This invite has already been accepted." }
}
// Can't be a member of your own account.
if (invite.owner_id === user.id) {
return { ok: false, error: "You can't accept an invite to your own account." }
}
// A user may be an active member of at most one account.
const existingMembership = await db.query.account_members.findFirst({
where: and(
eq(account_members.member_id, user.id),
eq(account_members.status, "active"),
ne(account_members.id, invite.id)
),
})
if (existingMembership) {
return {
ok: false,
error:
"You're already an active member of another account. Leave it before joining a new one.",
}
}
try {
// Guard the update on status='pending' so two concurrent accepts can't both win.
const [updated] = await db
.update(account_members)
.set({
member_id: user.id,
status: "active",
accepted_at: new Date().toISOString(),
})
.where(and(eq(account_members.id, invite.id), eq(account_members.status, "pending")))
.returning({ id: account_members.id })
if (!updated) {
return { ok: false, error: "This invite is no longer available." }
}
} catch {
return { ok: false, error: "Something went wrong accepting the invite. Please try again." }
}
return { ok: true }
}
+188
View File
@@ -0,0 +1,188 @@
"use server"
import { revalidatePath } from "next/cache"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent, type WebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
import { generateWebhookSecret } from "@/lib/webhooks/deliver"
import { deliverTestPing } from "@/lib/webhooks/emit"
// ============================================================================
// Webhook endpoint management (dashboard, session-authed).
//
// Endpoints belong to the ACCOUNT OWNER (team-aware) so every event across the
// portfolio is delivered. Writes require canWrite (viewers are read-only). The
// signing secret is stored so it can be shown in the dashboard and used to sign
// deliveries; it is not a bearer credential.
// ============================================================================
const SETTINGS_PATH = "/settings/webhooks"
export type WebhookEndpointDTO = {
id: string
url: string
description: string | null
events: string[]
secret: string
status: "active" | "disabled"
source: "dashboard" | "api" | "zapier"
last_success_at: string | null
last_error_at: string | null
last_error: string | null
failure_count: number
created_at: string
}
function toDTO(row: typeof webhook_endpoints.$inferSelect): WebhookEndpointDTO {
return {
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,
}
}
/** Resolve the writing account owner or throw a user-facing error. */
async function requireWriter(): Promise<string> {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) throw new Error("You do not have permission to manage webhooks.")
return ctx.ownerId
}
function sanitizeEvents(events: unknown): WebhookEvent[] {
if (!Array.isArray(events)) return []
return Array.from(new Set(events.filter(isWebhookEvent)))
}
export async function createWebhookEndpoint(input: {
url: string
events: string[]
description?: string
}): Promise<WebhookEndpointDTO> {
const ownerId = await requireWriter()
const parsed = webhookEndpointSchema.safeParse(input)
if (!parsed.success) {
throw new Error(parsed.error.issues[0]?.message ?? "Invalid webhook configuration")
}
try {
await assertSafeWebhookUrl(parsed.data.url)
} catch (e) {
throw new Error(e instanceof WebhookUrlError ? e.message : "Invalid webhook URL")
}
const [row] = await db
.insert(webhook_endpoints)
.values({
user_id: ownerId,
url: parsed.data.url,
description: parsed.data.description || null,
events: sanitizeEvents(parsed.data.events),
secret: generateWebhookSecret(),
source: "dashboard",
})
.returning()
revalidatePath(SETTINGS_PATH)
return toDTO(row)
}
export async function updateWebhookEndpoint(
id: string,
input: { url?: string; events?: string[]; description?: string; status?: "active" | "disabled" }
): Promise<WebhookEndpointDTO> {
const ownerId = await requireWriter()
if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id")
const existing = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId)),
})
if (!existing) throw new Error("Webhook not found")
const patch: Partial<typeof webhook_endpoints.$inferInsert> = {}
if (input.url !== undefined) {
const parsed = webhookEndpointSchema.shape.url.safeParse(input.url)
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Invalid URL")
try {
await assertSafeWebhookUrl(parsed.data)
} catch (e) {
throw new Error(e instanceof WebhookUrlError ? e.message : "Invalid webhook URL")
}
patch.url = parsed.data
}
if (input.events !== undefined) patch.events = sanitizeEvents(input.events)
if (input.description !== undefined) patch.description = input.description.slice(0, 200) || null
if (input.status !== undefined) {
if (input.status !== "active" && input.status !== "disabled") throw new Error("Invalid status")
patch.status = input.status
}
const [row] = await db
.update(webhook_endpoints)
.set(patch)
.where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId)))
.returning()
revalidatePath(SETTINGS_PATH)
return toDTO(row)
}
export async function deleteWebhookEndpoint(id: string): Promise<void> {
const ownerId = await requireWriter()
if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id")
await db
.delete(webhook_endpoints)
.where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId)))
revalidatePath(SETTINGS_PATH)
}
export async function rotateWebhookSecret(id: string): Promise<{ secret: string }> {
const ownerId = await requireWriter()
if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id")
const secret = generateWebhookSecret()
const [row] = await db
.update(webhook_endpoints)
.set({ secret })
.where(and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId)))
.returning({ id: webhook_endpoints.id })
if (!row) throw new Error("Webhook not found")
revalidatePath(SETTINGS_PATH)
return { secret }
}
export async function sendTestWebhook(
id: string
): Promise<{ ok: boolean; responseStatus: number | null; error: string | null }> {
const ownerId = await requireWriter()
if (typeof id !== "string" || !id) throw new Error("Invalid endpoint id")
const endpoint = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ownerId)),
})
if (!endpoint) throw new Error("Webhook not found")
const result = await deliverTestPing(endpoint)
revalidatePath(SETTINGS_PATH)
return result
}
+4 -1
View File
@@ -3,11 +3,14 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { activity_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "50", 10))
@@ -15,7 +18,7 @@ export async function GET(request: Request) {
const data = await db
.select()
.from(activity_log)
.where(eq(activity_log.user_id, user.id))
.where(eq(activity_log.user_id, ownerId))
.orderBy(desc(activity_log.created_at))
.limit(limit)
+12 -9
View File
@@ -12,6 +12,7 @@ import {
expenses,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts"
@@ -23,8 +24,10 @@ export async function POST(request: Request) {
const quota = await enforceAiQuota(user.id, "ai_ask")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ownerId = await getEffectiveOwnerId(user.id)
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
where: eq(profiles.id, ownerId),
columns: { full_name: true },
})
@@ -45,7 +48,7 @@ export async function POST(request: Request) {
total_units: properties.total_units,
})
.from(properties)
.where(eq(properties.user_id, user.id)),
.where(eq(properties.user_id, ownerId)),
db
.select({
id: units.id,
@@ -56,7 +59,7 @@ export async function POST(request: Request) {
status: units.status,
})
.from(units)
.where(eq(units.user_id, user.id)),
.where(eq(units.user_id, ownerId)),
db
.select({
id: tenants.id,
@@ -69,7 +72,7 @@ export async function POST(request: Request) {
move_in_date: tenants.move_in_date,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
.where(and(eq(tenants.user_id, ownerId), eq(tenants.status, "active"))),
db
.select({
amount: rent_payments.amount,
@@ -79,7 +82,7 @@ export async function POST(request: Request) {
property_id: rent_payments.property_id,
})
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, threeMonthsAgo))),
.where(and(eq(rent_payments.user_id, ownerId), gte(rent_payments.due_date, threeMonthsAgo))),
db
.select({
id: maintenance_requests.id,
@@ -91,7 +94,7 @@ export async function POST(request: Request) {
created_at: maintenance_requests.created_at,
})
.from(maintenance_requests)
.where(and(eq(maintenance_requests.user_id, user.id), inArray(maintenance_requests.status, ["open", "in_progress"]))),
.where(and(eq(maintenance_requests.user_id, ownerId), inArray(maintenance_requests.status, ["open", "in_progress"]))),
db
.select({
id: leases.id,
@@ -102,7 +105,7 @@ export async function POST(request: Request) {
status: leases.status,
})
.from(leases)
.where(and(eq(leases.user_id, user.id), eq(leases.status, "active"))),
.where(and(eq(leases.user_id, ownerId), eq(leases.status, "active"))),
db
.select({
amount: expenses.amount,
@@ -112,7 +115,7 @@ export async function POST(request: Request) {
property_id: expenses.property_id,
})
.from(expenses)
.where(and(eq(expenses.user_id, user.id), gte(expenses.expense_date, threeMonthsAgo))),
.where(and(eq(expenses.user_id, ownerId), gte(expenses.expense_date, threeMonthsAgo))),
])
// Build summary stats
@@ -162,5 +165,5 @@ Answer the landlord's question in a helpful, concise, and professional manner. U
const answer = completion.choices[0].message.content ?? ""
return NextResponse.json({ answer })
return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } })
}
+4 -1
View File
@@ -3,15 +3,18 @@ import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_recommendations } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const all = await db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.where(eq(ai_recommendations.user_id, ownerId))
const approved = all.filter((r) => r.status === "approved")
const dismissed = all.filter((r) => r.status === "dismissed")
+5 -2
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -14,6 +15,8 @@ export async function POST(request: Request) {
const quota = await enforceAiQuota(user.id, "ai_maintenance_summary")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ownerId = await getEffectiveOwnerId(user.id)
const { property_id } = await request.json() as { property_id: string }
const requests = await db
@@ -29,10 +32,10 @@ export async function POST(request: Request) {
resolved_at: maintenance_requests.resolved_at,
})
.from(maintenance_requests)
.where(and(eq(maintenance_requests.user_id, user.id), eq(maintenance_requests.property_id, property_id)))
.where(and(eq(maintenance_requests.user_id, ownerId), eq(maintenance_requests.property_id, property_id)))
const property = await db.query.properties.findFirst({
where: and(eq(properties.id, property_id), eq(properties.user_id, user.id)),
where: and(eq(properties.id, property_id), eq(properties.user_id, ownerId)),
columns: { name: true },
})
+18 -11
View File
@@ -12,6 +12,7 @@ import {
expenses,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -21,10 +22,12 @@ export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const data = 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))
.limit(30)
@@ -38,13 +41,17 @@ export async function POST() {
const quota = await enforceAiQuota(user.id, "ai_predictions")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
const now = new Date()
const sixMonthsAgo = new Date(now)
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
const sixMonthsAgoDate = sixMonthsAgo.toISOString().slice(0, 10)
const [propertiesData, unitsData, tenantsData, payments, maintenance, leasesData, expensesData] = await Promise.all([
db.select({ id: properties.id, name: properties.name }).from(properties).where(eq(properties.user_id, user.id)),
db.select({ id: properties.id, name: properties.name }).from(properties).where(eq(properties.user_id, ownerId)),
db
.select({
id: units.id,
@@ -54,7 +61,7 @@ export async function POST() {
status: units.status,
})
.from(units)
.where(eq(units.user_id, user.id)),
.where(eq(units.user_id, ownerId)),
db
.select({
id: tenants.id,
@@ -64,7 +71,7 @@ export async function POST() {
property_id: tenants.property_id,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
.where(and(eq(tenants.user_id, ownerId), eq(tenants.status, "active"))),
db
.select({
amount: rent_payments.amount,
@@ -73,7 +80,7 @@ export async function POST() {
property_id: rent_payments.property_id,
})
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, sixMonthsAgoDate)))
.where(and(eq(rent_payments.user_id, ownerId), gte(rent_payments.due_date, sixMonthsAgoDate)))
.orderBy(rent_payments.due_date),
db
.select({
@@ -84,7 +91,7 @@ export async function POST() {
property_id: maintenance_requests.property_id,
})
.from(maintenance_requests)
.where(eq(maintenance_requests.user_id, user.id)),
.where(eq(maintenance_requests.user_id, ownerId)),
db
.select({
tenant_id: leases.tenant_id,
@@ -94,7 +101,7 @@ export async function POST() {
status: leases.status,
})
.from(leases)
.where(eq(leases.user_id, user.id)),
.where(eq(leases.user_id, ownerId)),
db
.select({
amount: expenses.amount,
@@ -103,7 +110,7 @@ export async function POST() {
property_id: expenses.property_id,
})
.from(expenses)
.where(and(eq(expenses.user_id, user.id), gte(expenses.expense_date, sixMonthsAgoDate))),
.where(and(eq(expenses.user_id, ownerId), gte(expenses.expense_date, sixMonthsAgoDate))),
])
// Build monthly revenue trend
@@ -183,10 +190,10 @@ Only return valid JSON, no other text.`
}
// Replace old predictions
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, user.id))
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId))
const toInsert = predictions.map((p: any) => ({
user_id: user.id,
user_id: ownerId,
type: p.type ?? "growth_opportunity",
title: p.title,
prediction: p.prediction,
@@ -199,7 +206,7 @@ Only return valid JSON, no other text.`
const inserted = toInsert.length > 0 ? await db.insert(ai_predictions).values(toInsert).returning() : []
await logActivity({
userId: user.id,
userId: ownerId,
type: "ai_action",
title: `AI generated ${inserted.length} predictions and risk alerts`,
entityType: "ai_predictions",
+7 -2
View File
@@ -3,12 +3,17 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { ai_recommendations } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { logActivity } from "@/lib/activity"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
const { id } = await params
const { status } = await request.json() as { status: "approved" | "dismissed" }
@@ -23,13 +28,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const [data] = await db
.update(ai_recommendations)
.set(updateData)
.where(and(eq(ai_recommendations.id, id), eq(ai_recommendations.user_id, user.id)))
.where(and(eq(ai_recommendations.id, id), eq(ai_recommendations.user_id, ownerId)))
.returning()
if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
await logActivity({
userId: user.id,
userId: ownerId,
type: "ai_action",
title: status === "approved"
? `AI recommendation approved: ${data.title}`
+18 -11
View File
@@ -12,6 +12,7 @@ import {
expenses,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client"
import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage"
@@ -21,10 +22,12 @@ export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const data = await db
.select()
.from(ai_recommendations)
.where(eq(ai_recommendations.user_id, user.id))
.where(eq(ai_recommendations.user_id, ownerId))
.orderBy(desc(ai_recommendations.created_at))
return NextResponse.json(data)
@@ -37,6 +40,10 @@ export async function POST() {
const quota = await enforceAiQuota(user.id, "ai_recommendations")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
// Fetch portfolio data
const now = new Date()
const threeMonthsAgo = new Date(now)
@@ -47,7 +54,7 @@ export async function POST() {
db
.select({ id: properties.id, name: properties.name, address_line1: properties.address_line1, city: properties.city })
.from(properties)
.where(eq(properties.user_id, user.id)),
.where(eq(properties.user_id, ownerId)),
db
.select({
id: units.id,
@@ -57,7 +64,7 @@ export async function POST() {
status: units.status,
})
.from(units)
.where(eq(units.user_id, user.id)),
.where(eq(units.user_id, ownerId)),
db
.select({
id: tenants.id,
@@ -69,7 +76,7 @@ export async function POST() {
move_in_date: tenants.move_in_date,
})
.from(tenants)
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
.where(and(eq(tenants.user_id, ownerId), eq(tenants.status, "active"))),
db
.select({
id: rent_payments.id,
@@ -80,7 +87,7 @@ export async function POST() {
property_id: rent_payments.property_id,
})
.from(rent_payments)
.where(and(eq(rent_payments.user_id, user.id), gte(rent_payments.due_date, threeMonthsAgoDate))),
.where(and(eq(rent_payments.user_id, ownerId), gte(rent_payments.due_date, threeMonthsAgoDate))),
db
.select({
id: maintenance_requests.id,
@@ -91,7 +98,7 @@ export async function POST() {
created_at: maintenance_requests.created_at,
})
.from(maintenance_requests)
.where(and(eq(maintenance_requests.user_id, user.id), inArray(maintenance_requests.status, ["open", "in_progress"]))),
.where(and(eq(maintenance_requests.user_id, ownerId), inArray(maintenance_requests.status, ["open", "in_progress"]))),
db
.select({
id: leases.id,
@@ -102,7 +109,7 @@ export async function POST() {
status: leases.status,
})
.from(leases)
.where(and(eq(leases.user_id, user.id), eq(leases.status, "active"))),
.where(and(eq(leases.user_id, ownerId), eq(leases.status, "active"))),
db
.select({
amount: expenses.amount,
@@ -111,7 +118,7 @@ export async function POST() {
expense_date: expenses.expense_date,
})
.from(expenses)
.where(and(eq(expenses.user_id, user.id), gte(expenses.expense_date, threeMonthsAgoDate))),
.where(and(eq(expenses.user_id, ownerId), gte(expenses.expense_date, threeMonthsAgoDate))),
])
const totalRevenue = payments.filter((p) => p.status === "paid").reduce((s, p) => s + Number(p.amount), 0)
@@ -175,10 +182,10 @@ Only return valid JSON, no other text.`
// Delete old pending recommendations and insert new ones
await db
.delete(ai_recommendations)
.where(and(eq(ai_recommendations.user_id, user.id), eq(ai_recommendations.status, "pending")))
.where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending")))
const toInsert = recommendations.map((r: any) => ({
user_id: user.id,
user_id: ownerId,
type: r.type ?? "opportunity",
title: r.title,
description: r.description,
@@ -192,7 +199,7 @@ Only return valid JSON, no other text.`
const inserted = toInsert.length > 0 ? await db.insert(ai_recommendations).values(toInsert).returning() : []
await logActivity({
userId: user.id,
userId: ownerId,
type: "ai_action",
title: `AI generated ${inserted.length} new recommendations`,
entityType: "ai_recommendations",
+121
View File
@@ -0,0 +1,121 @@
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles, rent_payments, leases, inspections } from "@/lib/db/schema"
// Public, token-authenticated iCal (ICS) subscription feed. A landlord subscribes
// to /api/calendar/<calendar_token>.ics in Google/Apple/Outlook and their rent
// due dates, lease expiries, and inspections appear (read-only, auto-refreshing).
export const dynamic = "force-dynamic"
const PRODID = "-//Property Management Network//Calendar//EN"
function icsDate(d: string): string {
return d.slice(0, 10).replace(/-/g, "")
}
function icsDatePlusOne(d: string): string {
const dt = new Date(d.slice(0, 10) + "T00:00:00Z")
dt.setUTCDate(dt.getUTCDate() + 1)
return dt.toISOString().slice(0, 10).replace(/-/g, "")
}
function esc(s: unknown): string {
return String(s ?? "").replace(/[\\;,]/g, (m) => "\\" + m).replace(/\r?\n/g, "\\n")
}
// Fold lines to 75 octets per RFC 5545.
function fold(line: string): string {
if (line.length <= 75) return line
const parts: string[] = []
let rest = line
parts.push(rest.slice(0, 75))
rest = rest.slice(75)
while (rest.length > 74) {
parts.push(" " + rest.slice(0, 74))
rest = rest.slice(74)
}
if (rest.length) parts.push(" " + rest)
return parts.join("\r\n")
}
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) {
const { token: raw } = await params
const token = raw.replace(/\.ics$/i, "")
if (!token) return new Response("Not found", { status: 404 })
const profile = await db.query.profiles.findFirst({
where: eq(profiles.calendar_token, token),
columns: { id: true },
})
if (!profile) return new Response("Not found", { status: 404 })
const ownerId = profile.id
const [payments, leaseList, inspList] = await Promise.all([
db.query.rent_payments.findMany({
where: eq(rent_payments.user_id, ownerId),
columns: { id: true, due_date: true, amount: true, status: 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, ownerId), eq(leases.status, "active")),
columns: { id: true, lease_end: true },
with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } } },
}),
db.query.inspections.findMany({
where: eq(inspections.user_id, ownerId),
columns: { id: true, date: true, type: true, status: true },
with: { property: { columns: { name: true } }, unit: { columns: { unit_number: true } } },
}),
])
const stamp = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
const out: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
`PRODID:${PRODID}`,
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Property Management Network",
"X-WR-TIMEZONE:UTC",
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
"X-PUBLISHED-TTL:PT6H",
]
const addEvent = (uid: string, date: string, summary: string, description: string) => {
out.push(
"BEGIN:VEVENT",
fold(`UID:${uid}@propertymanagement.network`),
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icsDate(date)}`,
`DTEND;VALUE=DATE:${icsDatePlusOne(date)}`,
fold(`SUMMARY:${esc(summary)}`),
fold(`DESCRIPTION:${esc(description)}`),
"TRANSP:TRANSPARENT",
"END:VEVENT"
)
}
for (const p of payments) {
if (!p.due_date) continue
const who = `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`.trim() || "Tenant"
const amt = `$${Number(p.amount).toLocaleString("en-US")}`
addEvent(`rent-${p.id}`, p.due_date, `Rent due — ${who} (${amt})`, `${p.status.toUpperCase()} · ${p.property?.name ?? ""}${p.unit ? ` Unit ${p.unit.unit_number}` : ""}`)
}
for (const l of leaseList) {
if (!l.lease_end) continue
const who = `${l.tenant?.first_name ?? ""} ${l.tenant?.last_name ?? ""}`.trim() || "Tenant"
addEvent(`lease-${l.id}`, l.lease_end, `Lease ends — ${who}`, `${l.property?.name ?? ""}`)
}
for (const ins of inspList) {
if (!ins.date) continue
const type = ins.type.replace("_", "-")
addEvent(`insp-${ins.id}`, ins.date, `${type} inspection`, `${ins.property?.name ?? ""}${ins.unit ? ` Unit ${ins.unit.unit_number}` : ""} · ${ins.status}`)
}
out.push("END:VCALENDAR")
return new Response(out.join("\r\n") + "\r\n", {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"',
"Cache-Control": "public, max-age=3600",
},
})
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
import { isAuthorizedCron } from "@/lib/cron-auth"
// Combined daily cron: rent reminders + overdue marking + lease expiry emails
// Runs daily at 9am (see vercel.json)
// Runs daily at 9am UTC (scheduled via DigitalOcean Functions — see DIGITALOCEAN.md)
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { follow_up_rules } from "@/lib/db/schema"
import { isAuthorizedCron } from "@/lib/cron-auth"
import { runFollowUpsForUser } from "@/lib/follow-ups"
// Automated follow-ups cron: runs every user's active follow-up rules.
// Runs daily at 10:00 UTC (see functions/project.yml → follow-ups trigger).
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Distinct user ids that have at least one active follow-up rule.
const rows = await db
.selectDistinct({ user_id: follow_up_rules.user_id })
.from(follow_up_rules)
.where(eq(follow_up_rules.is_active, true))
let processed = 0
let total = 0
for (const { user_id } of rows) {
try {
const result = await runFollowUpsForUser(user_id)
total += result.sent
processed++
} catch (err) {
console.error(`follow-ups cron failed for user ${user_id}:`, err)
}
}
return NextResponse.json({ processed, sent: total })
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { db } from "@/lib/db"
import { rent_payments, expenses } from "@/lib/db/schema"
import { isAuthorizedCron } from "@/lib/cron-auth"
// Vercel Cron: runs daily at 8am (see vercel.json)
// Scheduled task: runs daily at 8am UTC (scheduled via DigitalOcean Functions — see DIGITALOCEAN.md)
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
-67
View File
@@ -1,67 +0,0 @@
import { NextResponse } from "next/server"
import { and, eq, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases } from "@/lib/db/schema"
import { sendEmail, leaseExpiryHtml } from "@/lib/email/send"
import { formatDate, daysUntil } from "@/lib/utils"
import { isAuthorizedCron } from "@/lib/cron-auth"
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const checkpoints = [
{ days: 60, field: "reminder_60_sent" as const },
{ days: 30, field: "reminder_30_sent" as const },
{ days: 7, field: "reminder_7_sent" as const },
]
let sent = 0
for (const { days, field } of checkpoints) {
const target = new Date()
target.setDate(target.getDate() + days)
const targetStr = target.toISOString().slice(0, 10)
const expiringLeases = await db.query.leases.findMany({
where: and(
eq(leases.status, "active"),
eq(leases[field], false),
lte(leases.lease_end, targetStr)
),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
for (const lease of expiringLeases) {
if (!lease.tenant?.email) continue
const daysLeft = daysUntil(lease.lease_end)
await sendEmail({
to: lease.tenant.email,
subject: `Your lease expires in ${daysLeft} days — ${lease.property?.name}`,
html: leaseExpiryHtml({
tenantName: `${lease.tenant.first_name} ${lease.tenant.last_name}`,
propertyName: lease.property?.name ?? "",
unitNumber: lease.unit?.unit_number ?? "—",
leaseEnd: formatDate(lease.lease_end),
daysLeft,
}),
})
await db
.update(leases)
.set({ [field]: true })
.where(eq(leases.id, lease.id))
sent++
}
}
return NextResponse.json({ reminders_sent: sent })
}
-79
View File
@@ -1,79 +0,0 @@
import { NextResponse } from "next/server"
import { and, eq, lt } from "drizzle-orm"
import { db } from "@/lib/db"
import { rent_payments } from "@/lib/db/schema"
import { sendEmail, rentDueReminderHtml, rentOverdueHtml } from "@/lib/email/send"
import { formatCurrency, formatDate } from "@/lib/utils"
import { isAuthorizedCron } from "@/lib/cron-auth"
// Called by Vercel Cron — runs daily
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const today = new Date().toISOString().slice(0, 10)
const in3Days = new Date()
in3Days.setDate(in3Days.getDate() + 3)
const in3DaysStr = in3Days.toISOString().slice(0, 10)
// Payments due in 3 days → send reminder
const upcoming = await db.query.rent_payments.findMany({
where: and(eq(rent_payments.status, "pending"), eq(rent_payments.due_date, in3DaysStr)),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
for (const payment of upcoming) {
if (!payment.tenant?.email) continue
await sendEmail({
to: payment.tenant.email,
subject: `Rent Due in 3 Days — ${payment.property?.name}`,
html: rentDueReminderHtml({
tenantName: `${payment.tenant.first_name} ${payment.tenant.last_name}`,
propertyName: payment.property?.name ?? "",
unitNumber: payment.unit?.unit_number ?? "—",
amount: formatCurrency(payment.amount),
dueDate: formatDate(payment.due_date),
}),
})
}
// Payments past due date → mark overdue + send notice
const pastDue = await db.query.rent_payments.findMany({
where: and(eq(rent_payments.status, "pending"), lt(rent_payments.due_date, today)),
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
property: { columns: { name: true } },
unit: { columns: { unit_number: true } },
},
})
for (const payment of pastDue) {
await db
.update(rent_payments)
.set({ status: "overdue" })
.where(eq(rent_payments.id, payment.id))
if (!payment.tenant?.email) continue
await sendEmail({
to: payment.tenant.email,
subject: `Rent Overdue — ${payment.property?.name}`,
html: rentOverdueHtml({
tenantName: `${payment.tenant.first_name} ${payment.tenant.last_name}`,
propertyName: payment.property?.name ?? "",
unitNumber: payment.unit?.unit_number ?? "—",
amount: formatCurrency(payment.amount),
dueDate: formatDate(payment.due_date),
}),
})
}
return NextResponse.json({
reminders_sent: upcoming.length,
marked_overdue: pastDue.length,
})
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server"
import { isAuthorizedCron } from "@/lib/cron-auth"
import { processDueDeliveries } from "@/lib/webhooks/deliver"
// Webhook delivery retry drain. The emitter attempts an immediate delivery when
// an event fires; this cron re-attempts anything still pending whose backoff
// window has elapsed (and covers deliveries orphaned by a process restart).
// Scheduled every 5 minutes via DigitalOcean Functions — see functions/project.yml.
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { processed, delivered } = await processDueDeliveries(200)
return NextResponse.json({ processed, delivered })
}
+10 -3
View File
@@ -4,14 +4,17 @@ import { db } from "@/lib/db"
import { documents } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { deleteFile } from "@/lib/storage"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { id } = await params
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, id), eq(documents.user_id, user.id)),
where: and(eq(documents.id, id), eq(documents.user_id, ownerId)),
})
if (!doc) return NextResponse.json({ error: "Not found" }, { status: 404 })
@@ -25,16 +28,20 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const { id } = await params
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, id), eq(documents.user_id, user.id)),
where: and(eq(documents.id, id), eq(documents.user_id, ownerId)),
columns: { storage_path: true },
})
if (!doc) return NextResponse.json({ error: "Not found" }, { status: 404 })
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, user.id)))
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
if (doc.storage_path) {
await deleteFile(doc.storage_path)
+55 -7
View File
@@ -3,19 +3,24 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { documents, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { saveFile } from "@/lib/storage"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
export async function GET(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id)
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
let propertyName = ""
if (propertyId) {
const prop = 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: { name: true },
})
propertyName = prop?.name ?? ""
@@ -23,7 +28,7 @@ export async function GET(request: Request) {
const data = await db.query.documents.findMany({
where: and(
eq(documents.user_id, user.id),
eq(documents.user_id, ownerId),
propertyId ? eq(documents.property_id, propertyId) : undefined
),
orderBy: desc(documents.created_at),
@@ -36,6 +41,10 @@ export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ctx = await getAccountContext(user.id)
const ownerId = ctx.ownerId
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const contentType = request.headers.get("content-type") ?? ""
if (contentType.includes("multipart/form-data")) {
@@ -47,20 +56,37 @@ export async function POST(request: Request) {
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 })
if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
// Verify the property belongs to the user before attaching a document to it.
const prop = 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 },
})
if (!prop) return NextResponse.json({ error: "Property not found" }, { status: 404 })
const { key, size, type } = await saveFile(file, { userId: user.id, scope: "documents" })
let saved
try {
saved = await saveFile(file, { userId: ownerId, scope: "documents" })
} catch (err) {
if (err instanceof StorageNotConfiguredError) {
console.error("[documents]", err.message)
return NextResponse.json(
{ error: "File uploads are temporarily unavailable. Please try again later." },
{ status: 503 }
)
}
throw err
}
const { key, size, type } = saved
const [data] = await db
.insert(documents)
.values({
user_id: user.id,
user_id: ownerId,
property_id: propertyId,
name: name || file.name,
category,
@@ -76,9 +102,31 @@ export async function POST(request: Request) {
// JSON fallback (metadata only)
const body = (await request.json()) as Record<string, unknown>
const propertyId = body.property_id as string | undefined
const tenantId = body.tenant_id as string | undefined
// Verify the property/tenant belong to the user before attaching a document.
if (!(await ownsProperty(ownerId, propertyId))) {
return NextResponse.json({ error: "Property not found" }, { status: 404 })
}
if (!(await ownsTenant(ownerId, tenantId))) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 })
}
// Whitelist insertable columns — never trust client-supplied user_id/id/created_at.
const [data] = await db
.insert(documents)
.values({ ...(body as typeof documents.$inferInsert), user_id: user.id })
.values({
user_id: ownerId,
property_id: propertyId as string,
tenant_id: tenantId,
name: body.name as string,
category: (body.category as typeof documents.$inferInsert.category) ?? "other",
file_url: body.file_url as string,
storage_path: body.storage_path as string | undefined,
file_type: body.file_type as string | undefined,
file_size: body.file_size as number | undefined,
})
.returning()
return NextResponse.json(data, { status: 201 })

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