Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9968531e4 | ||
|
|
969d5d4c8a |
+172
@@ -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
|
||||
@@ -16,7 +16,6 @@ storage
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
|
||||
# Editor / OS noise
|
||||
|
||||
+88
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (0000–0002)
|
||||
is already applied to production. Redeploys reuse the same image tag — App Platform pulls
|
||||
the new `:latest` on push (`deploy_on_push`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Cron — DigitalOcean Functions
|
||||
|
||||
The two jobs are triggered by DO Functions schedulers hitting the app's protected
|
||||
endpoints. Create `functions/.env` (gitignored):
|
||||
|
||||
```
|
||||
APP_BASE_URL=https://<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
@@ -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))"
|
||||
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex h-[520px] items-center justify-center text-sm text-white/30">Loading calendar…</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't available yet. Refresh the page and try again.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { getEffectiveOwnerId } from "@/lib/account"
|
||||
import {
|
||||
getDashboardStats,
|
||||
getRecentRentPayments,
|
||||
@@ -33,20 +34,11 @@ function GreetingBanner({ name }: { name: string }) {
|
||||
const dateStr = now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-8">
|
||||
<div>
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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)),
|
||||
])
|
||||
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
@@ -19,8 +19,8 @@ const typeColors: Record<string, string> = {
|
||||
}
|
||||
|
||||
const statusIcon: Record<string, React.ElementType> = {
|
||||
draft: Clock,
|
||||
completed: CheckCircle2,
|
||||
draft: Clock,
|
||||
complete: CheckCircle2,
|
||||
}
|
||||
|
||||
export function InspectionManager({ inspections: initial, properties }: { inspections: any[]; properties: any[] }) {
|
||||
@@ -44,7 +44,7 @@ export function InspectionManager({ inspections: initial, properties }: { inspec
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
|
||||
|
||||
async function toggleStatus(id: string, current: string) {
|
||||
const next = current === "completed" ? "draft" : "completed"
|
||||
const next = current === "complete" ? "draft" : "complete"
|
||||
const res = await fetch(`/api/inspections/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -52,7 +52,7 @@ export function InspectionManager({ inspections: initial, properties }: { inspec
|
||||
})
|
||||
if (res.ok) {
|
||||
setInspections(v => v.map(i => i.id === id ? { ...i, status: next } : i))
|
||||
toast.success(next === "completed" ? "Marked complete" : "Marked draft")
|
||||
toast.success(next === "complete" ? "Marked complete" : "Marked draft")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,9 +161,9 @@ export function InspectionManager({ inspections: initial, properties }: { inspec
|
||||
<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
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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,14 +117,22 @@ export default async function LeasesPage() {
|
||||
<DaysChip days={days} status={lease.status} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
{canRenew && (
|
||||
<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}`}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
|
||||
>
|
||||
Renew <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/leases/new?tenant_id=${lease.tenant_id}&property_id=${lease.property_id}&unit_id=${lease.unit_id ?? ""}&rent_amount=${lease.rent_amount}`}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-indigo-400 hover:text-indigo-300 transition"
|
||||
href={`/leases/${lease.id}`}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-white/50 hover:text-white transition"
|
||||
>
|
||||
Renew <ArrowRight className="h-3 w-3" />
|
||||
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 } },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)),
|
||||
])
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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,8 +172,11 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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 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>
|
||||
))}
|
||||
@@ -176,6 +184,28 @@ export default async function PropertyDetailPage({ params }: { params: Promise<{
|
||||
)}
|
||||
</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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ?? []} />
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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'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" && (
|
||||
<PortalButton />
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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're a {ctx.role} of {ownerName}'s account
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
You're working inside {ownerName}'s portfolio.{" "}
|
||||
{ctx.canWrite
|
||||
? "You can view and edit their data."
|
||||
: "You have read-only access to their data."}{" "}
|
||||
Only the account owner can manage team members.
|
||||
</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's secret so you can verify it'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 } },
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
|
||||
Vendored
+5
-2
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "Authorization: Bearer YOUR_API_KEY"</p>
|
||||
<p>curl {BASE_URL}/properties \</p>
|
||||
<p className="pl-4">-H "Authorization: Bearer pmn_live_..."</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">{"{"} "data": [...], "count": 12 {"}"}</p>
|
||||
<br />
|
||||
<p className="text-white/30">// Error</p>
|
||||
<p className="text-white/30">{"// Success (single / create)"}</p>
|
||||
<p className="text-emerald-300">{"{"} "data": {"{"} ... {"}"} {"}"}</p>
|
||||
<br />
|
||||
<p className="text-white/30">{"// Error"}</p>
|
||||
<p className="text-red-300">{"{"} "error": {"{"} "code": 401, "message": "Unauthorized" {"}"} {"}"}</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'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"
|
||||
>
|
||||
Join waitlist <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
{/* 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'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]" : ""}`}
|
||||
>
|
||||
<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 & 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=<unix>,v1=<hex></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'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">"id": "evt_9f2c…",</p>
|
||||
<p className="pl-4">"event": "tenant.created",</p>
|
||||
<p className="pl-4">"created_at": "2026-07-02T12:00:00.000Z",</p>
|
||||
<p className="pl-4">"data": {"{"} "tenant": {"{"} … {"}"} {"}"}</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: <delivery id></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>
|
||||
|
||||
@@ -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>—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>—used to remember
|
||||
interface preferences, such as layout and theme, on your device.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Stripe cookies</strong>—set by Stripe during payment to help prevent fraud
|
||||
and to support secure checkout.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cloudflare Turnstile challenge cookie</strong>—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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{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 sm:col-span-1">{row.purpose}</span>
|
||||
<span className="text-white/40">{row.expires}</span>
|
||||
</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]" : ""}`}>
|
||||
<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">{row.expires}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</LegalPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>“as is”</strong> and{" "}
|
||||
<strong>“as available”</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>
|
||||
)
|
||||
}
|
||||
@@ -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’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’s behalf. With respect to the Customer’s own account data
|
||||
(for example, the name and contact details of the account holder and billing
|
||||
information), we act as a <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’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’s behalf, we shall:</p>
|
||||
<ul>
|
||||
<li>
|
||||
process Personal Data only on the Customer’s documented instructions,
|
||||
including with regard to international transfers, unless required to do
|
||||
otherwise by law (in which case we shall inform the Customer of that legal
|
||||
requirement before processing, unless prohibited from doing so);
|
||||
</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’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’s obligation to respond to
|
||||
requests from Data Subjects exercising their rights under Applicable Data
|
||||
Protection Law. Where we receive a request directly from a Data Subject in
|
||||
respect of Personal Data processed on the Customer’s behalf, we shall,
|
||||
unless legally required to respond, forward that request to the Customer without
|
||||
undue delay.
|
||||
</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’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’s
|
||||
choice, delete or return all Personal Data processed on the Customer’s
|
||||
behalf, and delete existing copies, within {LEGAL.dataDeletionDays} days, save
|
||||
where retention of the Personal Data is required by Applicable Data Protection
|
||||
Law or other law, in which case we shall protect that Personal Data and process
|
||||
it only as necessary for the purpose that requires its retention.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="liability" heading="11. Liability">
|
||||
<p>
|
||||
Each party’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’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>
|
||||
)
|
||||
}
|
||||
+144
-127
@@ -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>
|
||||
<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>
|
||||
{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>
|
||||
</Section>
|
||||
|
||||
<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>
|
||||
<p>
|
||||
Property Management Network ("we", "us", "our") is the data controller for personal data collected through our platform.
|
||||
We are committed to complying with the General Data Protection Regulation (EU) 2016/679 (GDPR)
|
||||
and the UK GDPR where applicable.
|
||||
</p>
|
||||
</div>
|
||||
<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> — to obtain confirmation of whether we
|
||||
process your personal data and to receive a copy of it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to rectification</strong> — to have inaccurate personal
|
||||
data corrected and incomplete data completed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to erasure</strong> — to have your personal data deleted in
|
||||
certain circumstances.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to restriction of processing</strong> — to limit how we
|
||||
process your personal data in certain circumstances.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to data portability</strong> — to receive your personal
|
||||
data in a structured, commonly used, machine-readable format.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to object</strong> — to object to processing that relies on
|
||||
our legitimate interests.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to withdraw consent</strong> — where processing is based on
|
||||
consent, to withdraw that consent at any time.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Right to lodge a complaint</strong> — to lodge a complaint with a
|
||||
supervisory authority.
|
||||
</li>
|
||||
</ul>
|
||||
</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}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Section id="legal-bases" heading="3. Legal bases for processing">
|
||||
<p>
|
||||
We process personal data on one or more of the following legal bases, depending
|
||||
on the context:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Contract</strong> — where processing is necessary to provide the
|
||||
Service you have requested.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legitimate interests</strong> — 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> — where you have given consent for a specific
|
||||
purpose.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legal obligation</strong> — 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">Legal basis for processing</h2>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<Section id="storage" heading="4. Data storage and international transfers">
|
||||
<p>
|
||||
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>
|
||||
</Section>
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white mb-2">Data storage and transfers</h2>
|
||||
<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).
|
||||
</p>
|
||||
</div>
|
||||
<Section id="retention" heading="5. Data retention">
|
||||
<p>
|
||||
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>
|
||||
</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>
|
||||
<Section id="breach" heading="6. Personal-data breach notification">
|
||||
<p>
|
||||
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>
|
||||
</Section>
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white mb-2">Data retention</h2>
|
||||
<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).
|
||||
</p>
|
||||
</div>
|
||||
<Section id="subprocessors" heading="7. Sub-processors">
|
||||
<p>
|
||||
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>
|
||||
</Section>
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white mb-2">Data breach notification</h2>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<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’s Office in the United Kingdom, or your national
|
||||
data-protection authority in the European Union).
|
||||
</p>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white mb-2">Contact & complaints</h2>
|
||||
<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).
|
||||
</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>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LegalContact email={LEGAL.dpoEmail} />
|
||||
</LegalPage>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
</div>
|
||||
<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>—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>
|
||||
<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’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>—such as your name, email address,
|
||||
password credentials, organization details, and preferences.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Portfolio data you enter</strong>—property, unit, Tenant, lease, rent,
|
||||
maintenance, and expense records that the Customer creates or uploads to the Service.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment metadata</strong>—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>—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>—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>—to provide the Service you have signed
|
||||
up for and to administer your account and billing.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legitimate interests</strong>—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>—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>—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>—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>—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>—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’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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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’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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+304
-13
@@ -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.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<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 & 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 & 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 “AS IS” AND “AS AVAILABLE” 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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user