commit 857b9a78112553e4875eab18ae3518e8a82e1a5a Author: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Tue Jun 23 20:36:07 2026 -0400 Initial import: property management SaaS + security hardening + admin dashboard Property Management Network — Next.js 16 (App Router), Better Auth, Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend. Includes: - Security hardening: access-control/IDOR fixes, TLS-by-default DB layer, constant-time cron auth, strict security headers, atomic AI quota gating, HTML/email output encoding, demo-backdoor disabled in production. - Superadmin dashboard at /admin (overview/MRR, server-paginated users with ban/impersonate/plan/delete, billing, platform activity + admin audit log, AI usage, system health) via the Better Auth admin plugin. - Seed/migration utility scripts under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8da72d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Dependencies & build output (reinstalled / rebuilt inside the image) +node_modules +.next +out +build +coverage + +# Secrets — never bake env files into the image +.env +.env.* + +# Local file storage (uploads live on a mounted volume, not in the image) +storage + +# Version control & tooling +.git +.gitignore +.gitattributes +.vercel +*.tsbuildinfo + +# Editor / OS noise +.DS_Store +.vscode +.idea + +# Docs not needed at runtime +DOCS +README.md +COOLIFY.md + +# Don't copy the Docker context files into the image +Dockerfile +.dockerignore +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9246bd1 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============================================ +# PROPERTY MANAGEMENT NETWORK — Environment Variables +# ============================================ +# Copy this file to .env.local and fill in your values. +# Never commit .env.local to version control. + +# === DATABASE (external Postgres) === +# Standard Postgres connection string. +DATABASE_URL=postgres://user:password@host:5432/dbname + +# === BETTER AUTH === +# Generate a secret: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +BETTER_AUTH_SECRET=your-random-secret-here +BETTER_AUTH_URL=http://localhost:3000 + +# Google OAuth — create credentials at https://console.cloud.google.com +# Authorized redirect URI: /api/auth/callback/google +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# === STORAGE (local disk) === +# Directory where uploaded files are stored (kept out of the public web root). +STORAGE_DIR=./storage + +# === 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 + +# 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 + +# === 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 + +# === APP === +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_APP_NAME=Property Management Network +CRON_SECRET=your-random-secret-string diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..55ca038 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,62 @@ +# ============================================================================ +# PROPERTY MANAGEMENT NETWORK — Production Environment +# ============================================================================ +# Set these in Coolify (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). +# ============================================================================ + +# === DATABASE (PostgreSQL) === +# Coolify Postgres (internal): postgres://USER:PASSWORD@:5432/DB +DATABASE_URL=postgres://user:password@db:5432/pmn + +# 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). +# require -> encrypted + verified (managed DBs with a public CA). +# DATABASE_CA -> optional custom CA cert (PEM) when verifying. +DATABASE_SSL=require +# DATABASE_CA= + +# Run pending migrations automatically when the container starts. +# Set to "false" for multi-replica deploys and run migrations as a one-off job. +RUN_MIGRATIONS_ON_START=true + +# === BETTER AUTH === +# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +BETTER_AUTH_SECRET=replace-with-a-64-char-hex-secret +# Public base URL of the app (no trailing slash). +BETTER_AUTH_URL=https://propertymanagement.network + +# Google OAuth (optional). Redirect URI: /api/auth/callback/google +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# === STORAGE (local disk — mount a persistent volume on this path) === +STORAGE_DIR=/app/storage + +# === 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 + +# === AI (OpenAI) === +OPENAI_API_KEY=sk-xxx + +# === EMAIL (Resend) === +RESEND_API_KEY=re_xxx +RESEND_FROM_EMAIL=noreply@propertymanagement.network + +# === APP (NEXT_PUBLIC_* — also set as Build Variables) === +NEXT_PUBLIC_APP_URL=https://propertymanagement.network +NEXT_PUBLIC_APP_NAME=Property Management Network + +# === CRON === +# Bearer token required by the /api/cron/* and /api/follow-ups/run endpoints. +CRON_SECRET=replace-with-a-random-string diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..73f19b0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Keep shell scripts LF so they run inside Linux containers even when the repo +# is checked out / edited on Windows. +*.sh text eol=lf +docker-entrypoint.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..570156a --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# local file storage (uploaded documents/photos) +/storage + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example +!.env.production.example + +# Legacy Supabase check script — contains a hardcoded service_role key. +# Excluded from version control; rotate that key and delete this file. +supabase/verify.mjs + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env.local +DOCS/ + +.vercel +.env*.local diff --git a/COOLIFY.md b/COOLIFY.md new file mode 100644 index 0000000..4ba4f24 --- /dev/null +++ b/COOLIFY.md @@ -0,0 +1,127 @@ +# Deploying Property Management Network on Coolify + +This app is a Next.js 16 (App Router) server that needs: + +- a **PostgreSQL** database, +- a **persistent volume** for uploaded files (documents/photos are stored on local disk under `STORAGE_DIR`), +- a few third-party API keys (Stripe, OpenAI, Resend), +- **scheduled tasks** for the rent/lease cron jobs (Coolify replaces `vercel.json` crons). + +The repo ships a production `Dockerfile` (standalone output), a `/api/health` liveness probe, and an entrypoint that runs database migrations on boot. + +There are two ways to deploy. **Path A (Dockerfile + separate Postgres) is recommended.** + +--- + +## Path A — Dockerfile build pack + Coolify Postgres (recommended) + +### 1. Create the database +In your Coolify project: **+ New → Database → PostgreSQL**. Once created, copy its **internal connection string** (looks like `postgres://postgres:PASSWORD@:5432/postgres`). Use the internal host — the app talks to it over Coolify's private network. + +### 2. Create the application +**+ New → Application → Public/Private Git Repository**, point it at this repo, and set **Build Pack = Dockerfile**. + +### 3. Set environment variables +Under the app's **Environment Variables**, add everything from [`.env.production.example`](.env.production.example). At minimum: + +| Variable | Notes | +|---|---| +| `DATABASE_URL` | Internal Postgres URL from step 1. | +| `DATABASE_SSL` | TLS policy. Use `disable` for Coolify's internal/private-network Postgres; `require` (default) for managed/external DBs; `no-verify` for self-signed certs. | +| `BETTER_AUTH_SECRET` | `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | +| `BETTER_AUTH_URL` | Your public URL, e.g. `https://propertymanagement.network` | +| `NEXT_PUBLIC_APP_URL` | Same public URL. **Also mark as a Build Variable** (see below). | +| `NEXT_PUBLIC_APP_NAME` | `Property Management Network` (Build Variable too). | +| `CRON_SECRET` | Random string; protects the cron endpoints. | +| `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | Email sending. | +| `STRIPE_*` | Billing (optional to start). | +| `OPENAI_API_KEY` | AI assistant (optional to start). | + +> **Build Variables:** `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_APP_NAME` are inlined into the browser bundle at build time. In Coolify, set them so they're **available at build** (toggle "Build Variable" / "Available at Buildtime"). They're passed to the image via `ARG`/`--build-arg`. + +### 4. Add a persistent volume for uploads +Uploaded files are written to `STORAGE_DIR` (default `/app/storage`). Without a volume they're lost on every redeploy. + +Under the app's **Storages → Add**: mount a persistent volume at the container path **`/app/storage`**. + +### 5. Domain & port +- Set the app's **Domain** to your URL; Coolify provisions HTTPS automatically. +- The container listens on **port 3000** (already `EXPOSE`d). Coolify usually detects this; set the port to `3000` if asked. + +### 6. Health check +The image has a built-in Docker `HEALTHCHECK` hitting `/api/health`. You can also set Coolify's health check path to `/api/health`. + +### 7. Deploy +Click **Deploy**. On boot the entrypoint runs `scripts/migrate.mjs` to apply migrations, then starts the server. Watch the deploy logs for `[migrate] Migrations applied successfully.` followed by the Next.js ready line. + +--- + +## Path B — Docker Compose (app + Postgres bundled) + +Use the included [`docker-compose.yml`](docker-compose.yml) with Coolify's **Docker Compose** build pack. It defines the `app` and a `db` (Postgres 17) plus named volumes `app-storage` and `db-data`. + +Set these env vars in Coolify (mark `NEXT_PUBLIC_*` and `POSTGRES_*` as available at build time): + +``` +POSTGRES_USER=pmn +POSTGRES_PASSWORD= +POSTGRES_DB=pmn +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=https://your-domain +NEXT_PUBLIC_APP_URL=https://your-domain +NEXT_PUBLIC_APP_NAME=Property Management Network +CRON_SECRET= +RESEND_API_KEY=... # plus STRIPE_*, OPENAI_API_KEY as needed +``` + +`DATABASE_URL` is composed automatically from the `POSTGRES_*` values inside the compose file. The app waits for the DB healthcheck before starting and migrations retry while Postgres comes up. + +--- + +## Database migrations + +Migrations live in `lib/db/migrations` (Drizzle). They run automatically on container start via the entrypoint. + +- To **disable** auto-migrate (e.g. when running more than one replica), set `RUN_MIGRATIONS_ON_START=false` and run them as a one-off instead: + ```sh + # From a Coolify terminal/exec into the container: + node scripts/migrate.mjs + ``` + +--- + +## Scheduled tasks (cron) + +Coolify does not read `vercel.json`. Recreate the two jobs under the app's **Scheduled Tasks**. Each runs a command inside the container; authenticate with the `CRON_SECRET` env var that's already present there. + +| Name | Schedule (UTC) | Command | +|---|---|---| +| Daily (rent reminders, overdue, lease expiry) | `0 9 * * *` | `wget -q -O- --header="Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/daily` | +| Late fees | `0 8 * * *` | `wget -q -O- --header="Authorization: Bearer $CRON_SECRET" http://127.0.0.1:3000/api/cron/late-fees` | + +(The `daily` route already combines rent reminders, overdue marking, and 60/30/7-day lease-expiry emails.) + +--- + +## Stripe webhook (if using billing) + +Point a Stripe webhook at `https:///api/stripe/webhook` and put its signing secret in `STRIPE_WEBHOOK_SECRET`. Subscribe to: `checkout.session.completed`, `customer.subscription.created/updated/deleted`, `invoice.payment_failed`, `payment_intent.succeeded`. + +--- + +## Post-deploy checklist + +- [ ] `https:///api/health` returns `{"status":"ok",...}` +- [ ] Home page shows **Property Management Network** branding +- [ ] Sign up / log in works (verifies `DATABASE_URL` + `BETTER_AUTH_*`) +- [ ] Upload a document, redeploy, confirm it persists (verifies the `/app/storage` volume) +- [ ] Trigger the `daily` scheduled task manually and confirm a 200 in logs +- [ ] (If billing) Stripe webhook delivers successfully + +--- + +## Notes + +- **Google OAuth:** set `GOOGLE_CLIENT_ID/SECRET` and add `/api/auth/callback/google` as an authorized redirect URI. +- **Scaling:** with more than one replica, disable per-instance auto-migration (`RUN_MIGRATIONS_ON_START=false`) and note that local-disk storage is per-container — move uploads to object storage (e.g. S3) if you scale horizontally. +- **TLS:** the app and migrator default to encrypted + certificate-verified Postgres connections. Set `DATABASE_SSL=disable` for Coolify's internal private-network Postgres (and the bundled compose DB), `require` for managed/external DBs with a public CA, or `no-verify` for self-signed certs (optionally supply `DATABASE_CA`). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eba9be7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# syntax=docker/dockerfile:1 + +# ───────────────────────────────────────────────────────────────────────────── +# Property Management Network — production image for Coolify / Docker +# Multi-stage build producing a slim Next.js standalone server. +# ───────────────────────────────────────────────────────────────────────────── + +FROM node:22-alpine AS base +# libc6-compat keeps some native/optional deps happy on Alpine. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# ── Install dependencies (cached on lockfile) ──────────────────────────────── +FROM base AS deps +COPY package.json package-lock.json ./ +RUN npm ci + +# ── Build the app ──────────────────────────────────────────────────────────── +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). +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_APP_NAME="Property Management Network" +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# ── Runtime image ──────────────────────────────────────────────────────────── +FROM base AS runner +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +# Uploaded documents/photos live here — mount a persistent volume on this path. +ENV STORAGE_DIR=/app/storage + +RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs + +# Next.js standalone output: server.js + the minimal node_modules it traced. +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Migration runner: the SQL files, the script, and the full drizzle-orm package +# (standalone tracing omits the migrator submodule the app never imports). +COPY --from=builder /app/lib/db/migrations ./lib/db/migrations +COPY --from=builder /app/node_modules/drizzle-orm ./node_modules/drizzle-orm +COPY scripts/migrate.mjs ./scripts/migrate.mjs +COPY docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh + +# Create the storage mount point owned by the runtime user. +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. +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))" + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8032476 --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +

+ + + Property Management Network + +

+ +# Property Management Network + +**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. + +--- + +## What it does + +Property Management Network replaces the spreadsheet + WhatsApp chaos that most small landlords live with. Key capabilities: + +- **Properties & units** — manage your entire portfolio with occupancy tracking +- **Tenant profiles** — contact info, lease history, payment records, and a private tenant portal +- **Rent tracking** — log payments, send Stripe payment links, auto-mark overdue balances +- **Maintenance requests** — status workflow (Open → In Progress → Resolved), tenant submissions via portal +- **Lease management** — expiry countdowns, automated 60/30/7-day email alerts +- **Expenses** — categorized logging with recurring expense support +- **Documents** — file vault per property with drag-and-drop upload to local disk, served through an auth-gated route +- **AI features** — AI-powered recommendations, predictions, and impact tracking (Pro+) +- **Automated emails** — rent reminders, overdue alerts, lease expiry notifications via Resend +- **Tenant portal** — token-based (no login), tenants can view rent history and submit maintenance + +--- + +## Revenue model + +| Plan | Price | Limits | +|------|-------|--------| +| Starter | Free | 1 property, 3 tenants, no AI | +| Pro | $29/mo | 10 properties, unlimited tenants, AI (50 calls/mo) | +| Landlord | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) | +| Lifetime | $199 one-time | Everything in Landlord, forever | + +Subscription billing via Stripe. Lifetime deal is ideal for Flippa buyers who want to offer an LTD to early customers. + +--- + +## Tech stack + +| Layer | Tech | +|-------|------| +| Framework | Next.js 16.2 (App Router, TypeScript) | +| Styling | Tailwind CSS + Geist font | +| Database | PostgreSQL (via Drizzle ORM) | +| Auth | Better Auth (email/password + Google OAuth) | +| Storage | Local disk (auth-gated file serving) | +| Payments | Stripe (subscriptions + payment links) | +| AI | OpenAI (gpt-4o-mini) | +| Email | Resend | +| Cron | Vercel Cron Jobs | +| Deploy | Vercel | + +--- + +## Setup + +### 1. Clone and install + +```bash +git clone +cd property-management-network +npm install +``` + +### 2. Configure environment variables + +```bash +cp .env.example .env.local +``` + +Fill in `.env.local`: + +```env +# Database (PostgreSQL via Drizzle ORM) +DATABASE_URL= + +# Auth (Better Auth) +BETTER_AUTH_URL=http://localhost:3000 +BETTER_AUTH_SECRET=your-random-secret-string +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# File storage (local disk) +STORAGE_DIR=./storage + +# Stripe +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 + +# App +NEXT_PUBLIC_APP_URL=http://localhost:3000 +CRON_SECRET=your-random-secret-string +``` + +### 3. Run database migrations + +The schema is managed with Drizzle ORM (see `drizzle.config.ts`). Point `DATABASE_URL` at your PostgreSQL instance in `.env.local`, then apply the migrations from `lib/db/migrations`: + +```bash +npm run db:migrate +``` + +To regenerate migrations after changing the schema, use `npm run db:generate`. For quick local prototyping you can push the schema directly with `npm run db:push`. + +### 4. 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` + +Set up a webhook at `https://yourdomain.com/api/stripe/webhook` listening to: +- `checkout.session.completed` +- `customer.subscription.created` +- `customer.subscription.updated` +- `customer.subscription.deleted` +- `invoice.payment_failed` +- `payment_intent.succeeded` + +### 5. Configure Resend + +Add a verified sending domain in your Resend dashboard. Update `RESEND_FROM_EMAIL` with your domain address. + +### 6. (Optional) Google OAuth + +Create OAuth credentials in the Google Cloud Console and set `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` to enable Google sign-in via Better Auth. + +### 7. Run locally + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +### 8. Deploy to Vercel + +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. + +--- + +## Project structure + +``` +app/ +├── (marketing)/ # Landing page, pricing, legal +├── (auth)/ # Login, signup, password reset +├── (dashboard)/ # All dashboard pages (auth-gated) +│ ├── dashboard/ # Overview + stats +│ ├── properties/ # Property + unit management +│ ├── tenants/ # Tenant profiles +│ ├── rent/ # Payment tracking +│ ├── maintenance/ # Maintenance requests +│ ├── leases/ # Lease tracking +│ ├── expenses/ # Expense logging +│ └── settings/ # Billing + profile +├── api/ +│ ├── properties/ # CRUD +│ ├── tenants/ # CRUD + auto unit assignment +│ ├── rent/ # CRUD + Stripe payment links +│ ├── maintenance/ # CRUD + status workflow +│ ├── leases/ # CRUD +│ ├── expenses/ # CRUD +│ ├── documents/ # Document metadata (files on local disk) +│ ├── ai/ # Rent receipts + maintenance summaries +│ ├── notifications/ # Send emails via Resend +│ ├── stripe/ # Checkout, portal, webhook +│ └── cron/ # Rent reminders + lease expiry alerts +└── tenant-portal/[token]/ # Public tenant portal (no login) + +lib/ +├── db/ # Drizzle schema, queries, migrations +├── auth.ts # Better Auth config +├── storage.ts # Local-disk file storage helpers +├── stripe/ # Client, plans, payment links +├── ai/ # OpenAI client + prompts +├── email/ # Resend client + HTML templates +└── validations/ # Zod schemas for all entities + +drizzle.config.ts # Drizzle ORM config (DATABASE_URL, migrations dir) +``` + +--- + +## Database schema + +11 tables, managed via Drizzle ORM: + +`profiles` · `properties` · `units` · `tenants` · `rent_payments` · `maintenance_requests` · `leases` · `expenses` · `documents` · `notifications` · `usage_events` + +Data isolation is enforced in the application layer: every API route authenticates via `getSessionUser()` and scopes its queries by `user_id`. There is no database-level RLS, so this query scoping must be maintained carefully on every new route and query. + +--- + +## Cron jobs + +| Job | Schedule | What it does | +|-----|----------|--------------| +| Rent reminders | Daily 9am UTC | Marks overdue payments, sends 3-day reminder emails | +| Lease expiry | Daily 10am UTC | Sends 60/30/7-day expiry alerts to landlord | + +Cron routes are protected with `CRON_SECRET` (Bearer token in `Authorization` header). + +--- + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4a8f04c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,77 @@ +# Security & Pre-Deployment Checklist + +> **⚠️ 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 +> 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. + +--- + +## 1. Rotate every secret before production / handoff + +Work through this list and rotate each item. Do **not** reuse any value that +ever appeared in the distributed `.env.local`. + +- [ ] **Postgres password** — change the database role's password (or provision a + brand-new role) and update `DATABASE_URL` everywhere it is configured. + Then revoke the old credential. +- [ ] **`BETTER_AUTH_SECRET`** — generate a fresh 32-byte secret: + ```bash + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + ``` + Rotating this invalidates existing sessions — expected and desired. +- [ ] **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. +- [ ] **`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 `. +- [ ] **Google OAuth** — if the client secret was present in the transfer, + rotate it in the Google Cloud Console. + +## 2. Secret hygiene + +- [ ] **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. +- [ ] Use distinct secrets per environment (dev / preview / production). + +## 3. Database / TLS + +- [ ] Set **`DATABASE_SSL=require`** in production so the connection uses + **verified TLS** (encrypted + certificate-verified). `DATABASE_SSL=disable` + is **only** for local / unix-socket development. + If the provider uses a private/custom CA, supply it via `DATABASE_CA`. +- [ ] Use a **managed Postgres on a private network** (or the provider's private + endpoint) rather than a database exposed on a public IP. + +## Resolved in code + +The following hardening has already been applied in this codebase: + +- **TLS enforcement** — `lib/db/index.ts` now defaults to verified TLS + (`rejectUnauthorized: true`) and never silently runs plaintext. Behavior is + controlled by the explicit `DATABASE_SSL` env var (`disable` / `no-verify` / + `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 + 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` + (protects the tenant-portal URL token), HSTS with preload, + `X-DNS-Prefetch-Control: off`, and a Content-Security-Policy. +- **Auth rate limiting** — `lib/auth.ts` enables better-auth's built-in rate + limiting (20 requests / 60s per IP) to slow brute-force and credential + stuffing. +- **Test-plan backdoor removed** — the development-only backdoor that bypassed + plan/subscription checks is disabled in production. diff --git a/app/(admin)/admin/activity/page.tsx b/app/(admin)/admin/activity/page.tsx new file mode 100644 index 0000000..b0b1bbd --- /dev/null +++ b/app/(admin)/admin/activity/page.tsx @@ -0,0 +1,172 @@ +import { getPlatformActivity, getAdminAuditLog } from "@/lib/db/admin-queries" +import { EmptyState } from "@/components/shared/empty-state" +import { formatDate } from "@/lib/utils" +import { + Activity, Shield, Ban, CreditCard, UserCog, Trash2, History, +} from "lucide-react" + +export const dynamic = "force-dynamic" + +// Type → small accent dot color for the platform activity list. +const typeDotColor: Record = { + rent_paid: "bg-emerald-400", + rent_overdue: "bg-red-400", + tenant_added: "bg-blue-400", + tenant_removed: "bg-orange-400", + maintenance_opened: "bg-yellow-400", + maintenance_resolved: "bg-emerald-400", + lease_created: "bg-indigo-400", + lease_expiring: "bg-amber-400", + expense_added: "bg-purple-400", + property_added: "bg-cyan-400", + inspection_completed: "bg-teal-400", + vendor_added: "bg-pink-400", + ai_action: "bg-violet-400", +} + +// Audit action → colored pill classes. +const actionPill: Record = { + ban: "border-red-500/20 bg-red-500/10 text-red-400", + plan_change: "border-indigo-500/20 bg-indigo-500/10 text-indigo-400", + impersonate: "border-amber-500/20 bg-amber-500/10 text-amber-400", + delete_user: "border-red-500/20 bg-red-500/10 text-red-400", +} +const DEFAULT_PILL = "border-white/[0.08] bg-white/[0.04] text-white/40" + +// Audit action → icon. +const actionIcon: Record = { + ban: Ban, + plan_change: CreditCard, + impersonate: UserCog, + delete_user: Trash2, +} + +function compactJson(value: unknown): string { + if (value === null || value === undefined) return "" + try { + return typeof value === "string" ? value : JSON.stringify(value) + } catch { + return "" + } +} + +export default async function AdminActivityPage() { + const [activity, audit] = await Promise.all([ + getPlatformActivity({ limit: 60 }), + getAdminAuditLog({ limit: 40 }), + ]) + + return ( +
+ {/* Heading */} +
+

Activity & Audit

+

+ Platform-wide events and administrative actions +

+
+ +
+ {/* ── Platform Activity ───────────────────────────────────────── */} +
+
+ +

Platform Activity

+ {activity.length} events +
+ + {activity.length === 0 ? ( + + ) : ( +
+ {activity.map((a) => ( +
+ +
+

{a.title}

+

+ {a.user_email ?? "Unknown user"} +

+
+

+ {formatDate(a.created_at)} +

+
+ ))} +
+ )} +
+ + {/* ── Admin Audit Log ─────────────────────────────────────────── */} +
+
+ +

Admin Audit Log

+ {audit.length} entries +
+ + {audit.length === 0 ? ( + + ) : ( +
+ {audit.map((entry) => { + const Icon = actionIcon[entry.action] ?? Shield + const meta = compactJson(entry.metadata) + return ( +
+
+ + + {entry.action} + + {entry.target_user_id && ( + + {entry.target_user_id} + + )} + + {formatDate(entry.created_at)} + +
+ {meta && ( +

+ {meta} +

+ )} + {entry.ip_address && ( +

+ {entry.ip_address} +

+ )} +
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/app/(admin)/admin/ai-usage/page.tsx b/app/(admin)/admin/ai-usage/page.tsx new file mode 100644 index 0000000..a569265 --- /dev/null +++ b/app/(admin)/admin/ai-usage/page.tsx @@ -0,0 +1,112 @@ +import { getAiUsageAggregates } from "@/lib/db/admin-queries" +import { StatsCard } from "@/components/dashboard/stats-card" +import { EmptyState } from "@/components/shared/empty-state" +import { Brain, BarChart3, Users } from "lucide-react" + +export const dynamic = "force-dynamic" + +export default async function AdminAiUsagePage() { + const { byType, totalThisMonth, topUsers } = await getAiUsageAggregates() + + const sortedByType = [...byType].sort((a, b) => b.count - a.count) + const maxTypeCount = sortedByType[0]?.count ?? 0 + + return ( +
+ {/* Heading */} +
+

AI Usage

+

+ AI event volume across the platform +

+
+ + {/* KPI */} +
+ +
+ +
+ {/* ── Usage by type ───────────────────────────────────────────── */} +
+
+ +

Usage by type

+
+ + {sortedByType.length === 0 ? ( + + ) : ( +
+ {sortedByType.map((row) => ( +
+
+ + {row.event_type} + + + {row.count.toLocaleString()} + +
+
+
+
+
+ ))} +
+ )} +
+ + {/* ── Top consumers ───────────────────────────────────────────── */} +
+
+ +

Top consumers

+ This month +
+ + {topUsers.length === 0 ? ( + + ) : ( +
+ {topUsers.map((u, i) => ( +
+ + {i + 1} + + + {u.email} + + + {u.count.toLocaleString()} + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/app/(admin)/admin/billing/page.tsx b/app/(admin)/admin/billing/page.tsx new file mode 100644 index 0000000..e025652 --- /dev/null +++ b/app/(admin)/admin/billing/page.tsx @@ -0,0 +1,183 @@ +import { + getPlanDistribution, + computeMrr, + getAtRiskSubscriptions, +} from "@/lib/db/admin-queries" +import { StatsCard } from "@/components/dashboard/stats-card" +import { EmptyState } from "@/components/shared/empty-state" +import { PLAN_PRICES, getPlanLabel } from "@/lib/stripe/plans" +import { formatCurrency, formatDate } from "@/lib/utils" +import type { Plan } from "@/types" +import { DollarSign, TrendingUp, Gem, CreditCard, Download, ShieldCheck } from "lucide-react" + +export const dynamic = "force-dynamic" + +const STATUS_LABELS: Record = { + past_due: "Past due", + unpaid: "Unpaid", + incomplete: "Incomplete", +} + +// Plan rows for the distribution table (in display order) +const PLAN_ROWS: { plan: Plan; amount: number; oneTime: boolean }[] = [ + { plan: "starter", amount: 0, oneTime: false }, + { plan: "pro", amount: PLAN_PRICES.pro?.amount ?? 29, oneTime: false }, + { plan: "landlord", amount: PLAN_PRICES.landlord?.amount ?? 59, oneTime: false }, + { plan: "lifetime", amount: PLAN_PRICES.lifetime?.amount ?? 199, oneTime: true }, +] + +export default async function AdminBillingPage() { + const dist = await getPlanDistribution() + const { mrr, arr, lifetimeRevenue } = computeMrr(dist) + const atRisk = await getAtRiskSubscriptions() + + const paidCustomers = dist.pro + dist.landlord + dist.lifetime + + return ( +
+ {/* Heading */} +
+
+

Billing

+

Revenue, plan mix, and subscription health

+
+ + + Export CSV + +
+ + {/* KPI cards */} +
+ + + + +
+ + {/* Plan distribution table */} +
+
+

Plan Distribution

+
+
+ + + + + + + + + + + {PLAN_ROWS.map(({ plan, amount, oneTime }) => { + const count = dist[plan] ?? 0 + const isStarter = plan === "starter" + return ( + + + + + + + ) + })} + + + + + + + + +
PlanSubscribersUnit PriceMonthly Contribution
+ {getPlanLabel(plan)} + {oneTime && ( + + one-time + + )} + {count.toLocaleString()} + {isStarter ? "—" : formatCurrency(amount)} + {oneTime && /once} + + {isStarter ? ( + + ) : oneTime ? ( + + {formatCurrency(count * amount)} + one-time + + ) : ( + {formatCurrency(count * amount)} + )} +
MRR Total{paidCustomers.toLocaleString()} + {formatCurrency(mrr)}
+
+
+ + {/* At-risk subscriptions table */} +
+
+

At-risk Subscriptions

+ {atRisk.length > 0 && ( + + {atRisk.length} + + )} +
+ + {atRisk.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {atRisk.map((s) => ( + + + + + + + ))} + +
EmailPlanStatusExpires
+ {s.email} + {s.full_name && {s.full_name}} + {getPlanLabel(s.plan as Plan)} + + {STATUS_LABELS[s.subscription_status ?? ""] ?? s.subscription_status ?? "Unknown"} + + + {s.plan_expires_at ? formatDate(s.plan_expires_at) : "—"} +
+
+ )} +
+
+ ) +} diff --git a/app/(admin)/admin/layout.tsx b/app/(admin)/admin/layout.tsx new file mode 100644 index 0000000..78b867d --- /dev/null +++ b/app/(admin)/admin/layout.tsx @@ -0,0 +1,28 @@ +import { requireAdmin } from "@/lib/session" +import { AdminSidebar } from "@/components/admin/admin-sidebar" +import { AdminHeader } from "@/components/admin/admin-header" +import { Breadcrumbs } from "@/components/dashboard/breadcrumbs" +import { PageTransition } from "@/components/dashboard/page-transition" +import { ScrollToTop } from "@/components/ui/scroll-to-top" + +export const dynamic = "force-dynamic" + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + // Gate #2 (after proxy.ts edge check): redirects non-admins. Every + // /api/admin route handler re-checks via getAdminSession() — gate #3. + const { user, profile } = await requireAdmin() + + return ( +
+ +
+ +
+ + {children} +
+
+ +
+ ) +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..ff4e39c --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -0,0 +1,142 @@ +import { + getAdminOverviewStats, + getSignupsTrend, + getAtRiskSubscriptions, +} from "@/lib/db/admin-queries" +import { StatsCard } from "@/components/dashboard/stats-card" +import { PlanDonut, SignupsBars } from "@/components/admin/admin-charts" +import { getPlanLabel } from "@/lib/stripe/plans" +import { formatCurrency } from "@/lib/utils" +import type { Plan } from "@/types" +import { + DollarSign, TrendingUp, Users, Activity, CreditCard, + UserPlus, Building2, Home, Banknote, Brain, AlertTriangle, +} from "lucide-react" + +export const dynamic = "force-dynamic" + +const STATUS_LABELS: Record = { + past_due: "Past due", + unpaid: "Unpaid", + incomplete: "Incomplete", +} + +export default async function AdminOverviewPage() { + const [stats, signupsTrend, atRisk] = await Promise.all([ + getAdminOverviewStats(), + getSignupsTrend(6), + getAtRiskSubscriptions(), + ]) + + return ( +
+ {/* Heading */} +
+

Platform Overview

+

Key metrics across all accounts

+
+ + {/* KPI grid */} +
+ + + + + + + + + + +
+ + {/* Charts row */} +
+ + +
+ + {/* At-risk subscriptions */} + {atRisk.length > 0 && ( +
+
+ +

At-risk subscriptions

+ + {atRisk.length} + +
+
+ {atRisk.slice(0, 8).map((s) => ( +
+
+

{s.email}

+

+ {s.full_name || "—"} · {getPlanLabel(s.plan as Plan)} +

+
+ + {STATUS_LABELS[s.subscription_status ?? ""] ?? s.subscription_status ?? "Unknown"} + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/app/(admin)/admin/system/page.tsx b/app/(admin)/admin/system/page.tsx new file mode 100644 index 0000000..9280dd1 --- /dev/null +++ b/app/(admin)/admin/system/page.tsx @@ -0,0 +1,107 @@ +import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries" +import { formatDate } from "@/lib/utils" +import { Settings, Database, Table2 } from "lucide-react" + +export const dynamic = "force-dynamic" + +function humanize(name: string): string { + return name + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) +} + +export default async function AdminSystemPage() { + const [{ counts, cronLastRun }, env] = await Promise.all([ + getSystemCounts(), + Promise.resolve(getEnvHealth()), + ]) + + return ( +
+ {/* Heading */} +
+

System Health

+

+ Configuration, database, and table statistics +

+
+ +
+ {/* ── Environment configuration ───────────────────────────────── */} +
+
+ +

Environment configuration

+
+
+ {env.map(({ key, present }) => ( +
+ {key} + + + + {present ? "Configured" : "Missing"} + + +
+ ))} +
+
+ + {/* ── Database ────────────────────────────────────────────────── */} +
+
+ +

Database

+
+
+
+ Connection + + + Connected + +
+
+ Cron last run + + {cronLastRun ? formatDate(cronLastRun) : "Never run"} + +
+
+
+
+ + {/* ── Table row counts ──────────────────────────────────────────── */} +
+
+ +

Table row counts

+
+
+ {Object.entries(counts).map(([name, value]) => ( +
+

+ {humanize(name)} +

+

+ {value.toLocaleString()} +

+
+ ))} +
+
+
+ ) +} diff --git a/app/(admin)/admin/users/[id]/page.tsx b/app/(admin)/admin/users/[id]/page.tsx new file mode 100644 index 0000000..b0c82ee --- /dev/null +++ b/app/(admin)/admin/users/[id]/page.tsx @@ -0,0 +1,215 @@ +import { notFound } from "next/navigation" +import { + Building2, + Home, + Users as UsersIcon, + FileText, + CreditCard, + Wrench, + Receipt, + Sparkles, + ShieldAlert, + Ban, + Activity, +} from "lucide-react" +import { getUserDetail } from "@/lib/db/admin-queries" +import { requireAdmin } from "@/lib/session" +import { BackButton } from "@/components/ui/back-button" +import { CopyButton } from "@/components/shared/copy-button" +import { UserActions } from "@/components/admin/user-actions" +import { formatDate, initials, cn } from "@/lib/utils" + +export const dynamic = "force-dynamic" + +const PLAN_BADGE: Record = { + starter: "border-white/15 bg-white/[0.04] text-white/40", + pro: "border-indigo-500/30 bg-indigo-500/10 text-indigo-300", + landlord: "border-violet-500/30 bg-violet-500/10 text-violet-300", + lifetime: "border-amber-500/30 bg-amber-500/10 text-amber-300", +} + +const COUNT_META: { key: string; label: string; icon: typeof Building2 }[] = [ + { key: "propertyCount", label: "Properties", icon: Building2 }, + { key: "unitCount", label: "Units", icon: Home }, + { key: "tenantCount", label: "Tenants", icon: UsersIcon }, + { key: "leaseCount", label: "Leases", icon: FileText }, + { key: "paymentCount", label: "Payments", icon: CreditCard }, + { key: "maintenanceCount", label: "Maintenance", icon: Wrench }, + { key: "expenseCount", label: "Expenses", icon: Receipt }, + { key: "aiCount", label: "AI calls", icon: Sparkles }, +] + +export default async function AdminUserDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const [detail, { user: me }] = await Promise.all([getUserDetail(id), requireAdmin()]) + + if (!detail) notFound() + + const { profile, account, counts, recentActivity } = detail + const isSelf = me.id === profile.id + const planKey = profile.plan ?? "starter" + + return ( +
+ + + {/* Header */} +
+
+
+
+ {initials(profile.full_name || profile.email)} +
+
+
+

{profile.full_name || "Unnamed user"}

+ + {planKey} + + {account?.role === "admin" && ( + + Admin + + )} + {account?.banned && ( + + Banned + + )} + {isSelf && ( + + You + + )} +
+

{profile.email}

+
+ {profile.phone && {profile.phone}} + {profile.company_name && {profile.company_name}} + Joined {formatDate(profile.created_at)} + {profile.id} +
+ {account?.banned && account.banReason && ( +

Ban reason: {account.banReason}

+ )} +
+
+
+
+ + {/* Counts grid */} +
+ {COUNT_META.map(({ key, label, icon: Icon }) => ( +
+
+ + {label} +
+

+ {(counts[key as keyof typeof counts] ?? 0).toLocaleString()} +

+
+ ))} +
+ +
+ {/* Left: billing + activity */} +
+ {/* Billing */} +
+

Billing

+
+
+
Plan
+
{planKey}
+
+
+
Status
+
+ {profile.subscription_status || "—"} +
+
+
+
Plan expires
+
+ {profile.plan_expires_at ? formatDate(profile.plan_expires_at) : "—"} +
+
+
+
Email verified
+
+ {account?.emailVerified ? "Yes" : "No"} +
+
+
+
Stripe customer ID
+
+ + {profile.stripe_customer_id || "—"} + + {profile.stripe_customer_id && } +
+
+
+
Stripe subscription ID
+
+ + {profile.stripe_subscription_id || "—"} + + {profile.stripe_subscription_id && } +
+
+
+
+ + {/* Recent activity */} +
+

Recent activity

+ {recentActivity.length === 0 ? ( +

No recent activity.

+ ) : ( +
    + {recentActivity.map((a) => ( +
  • +
    + +
    +
    +

    {a.title}

    +

    + {a.type} · {formatDate(a.created_at)} +

    +
    +
  • + ))} +
+ )} +
+
+ + {/* Right: actions */} +
+ +
+
+
+ ) +} diff --git a/app/(admin)/admin/users/page.tsx b/app/(admin)/admin/users/page.tsx new file mode 100644 index 0000000..b8eb410 --- /dev/null +++ b/app/(admin)/admin/users/page.tsx @@ -0,0 +1,33 @@ +import { getUsersPage } from "@/lib/db/admin-queries" +import { UsersTable } from "@/components/admin/users-table" + +export const dynamic = "force-dynamic" + +export default async function AdminUsersPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; page?: string; plan?: string; sort?: string; dir?: string }> +}) { + const { q, page, plan, sort, dir } = await searchParams + + const result = await getUsersPage({ + q, + page: Number(page) || 1, + plan, + sort, + dir: dir === "asc" ? "asc" : dir === "desc" ? "desc" : undefined, + }) + + return ( +
+
+

Users

+

+ Manage accounts, plans and access across the platform. +

+
+ + +
+ ) +} diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..3fbeb0e --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,69 @@ +import Link from "next/link" +import { Logo } from "@/components/shared/logo" +import { resetPassword } from "@/app/actions/auth" + +export default async function ForgotPasswordPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; success?: string }> +}) { + const params = await searchParams + const error = params.error + const success = params.success + + return ( +
+
+ +

Reset your password

+

+ Enter your email and we'll send a reset link +

+
+ +
+ {error && ( +
+ {decodeURIComponent(error)} +
+ )} + {success === "email-sent" && ( +
+ Check your email — reset link sent. +
+ )} + +
+
+ + +
+ + +
+ +

+ Remember your password?{" "} + + Back to sign in + +

+
+
+ ) +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..2b684e7 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next" + +export const metadata: Metadata = { + title: "Sign in to Property Management Network", +} + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..68f952e --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,132 @@ +import Link from "next/link" +import { Logo } from "@/components/shared/logo" +import { signIn, signInWithGoogle } from "@/app/actions/auth" + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; success?: string }> +}) { + const params = await searchParams + const error = params.error + const success = params.success + + return ( +
+
+ +

Welcome back

+

Sign in to your account

+
+ +
+ {/* Google OAuth */} +
+ +
+ +
+
+
+
+
+ or continue with email +
+
+ + {/* Error / Success messages */} + {error && ( +
+ {decodeURIComponent(error)} +
+ )} + {success === "password-updated" && ( +
+ Password updated. Sign in below. +
+ )} + + {/* Email + Password form */} +
+
+ + +
+ +
+
+ + + Forgot password? + +
+ +
+ + +
+ +

+ Don't have an account?{" "} + + Sign up free + +

+
+
+ ) +} + +function GoogleIcon() { + return ( + + + + + + + ) +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..2cbc667 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,156 @@ +import Link from "next/link" +import { Logo } from "@/components/shared/logo" +import { signUp, signInWithGoogle } from "@/app/actions/auth" + +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; success?: string }> +}) { + const params = await searchParams + const error = params.error + const success = params.success + + if (success === "check-email") { + return ( +
+
+ +
+
+
+ ✉️ +
+

Check your email

+

+ We sent a confirmation link to your email. Click it to activate your account. +

+ + Back to sign in + +
+
+ ) + } + + return ( +
+
+ +

Create your account

+

Start managing your properties for free

+
+ +
+ {/* Google OAuth */} +
+ +
+ +
+
+
+
+
+ or sign up with email +
+
+ + {error && ( +
+ {decodeURIComponent(error)} +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ By signing up you agree to our{" "} + Terms + {" "}and{" "} + Privacy Policy. +

+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ) +} + +function GoogleIcon() { + return ( + + + + + + + ) +} diff --git a/app/(auth)/update-password/page.tsx b/app/(auth)/update-password/page.tsx new file mode 100644 index 0000000..22bf9f8 --- /dev/null +++ b/app/(auth)/update-password/page.tsx @@ -0,0 +1,62 @@ +import Link from "next/link" +import { Logo } from "@/components/shared/logo" +import { updatePassword } from "@/app/actions/auth" + +export default async function UpdatePasswordPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; token?: string }> +}) { + const params = await searchParams + const error = params.error + const token = params.token ?? "" + + return ( +
+
+ +

Set new password

+

Choose a strong password

+
+ +
+ {error && ( +
+ {decodeURIComponent(error)} +
+ )} + +
+ +
+ + +
+ + +
+ +

+ + Back to sign in + +

+
+
+ ) +} diff --git a/app/(dashboard)/activity/activity-feed.tsx b/app/(dashboard)/activity/activity-feed.tsx new file mode 100644 index 0000000..30960e6 --- /dev/null +++ b/app/(dashboard)/activity/activity-feed.tsx @@ -0,0 +1,103 @@ +"use client" + +import { useState } from "react" +import { formatDistanceToNow } from "date-fns" +import { + DollarSign, UserPlus, Wrench, FileText, AlertTriangle, + Building2, ClipboardCheck, Users, Receipt, Zap, Activity, +} from "lucide-react" + +const typeConfig: Record = { + rent_paid: { icon: DollarSign, color: "text-emerald-400", bg: "bg-emerald-500/10" }, + rent_overdue: { icon: AlertTriangle, color: "text-red-400", bg: "bg-red-500/10" }, + tenant_added: { icon: UserPlus, color: "text-blue-400", bg: "bg-blue-500/10" }, + tenant_removed: { icon: Users, color: "text-orange-400", bg: "bg-orange-500/10" }, + maintenance_opened: { icon: Wrench, color: "text-yellow-400", bg: "bg-yellow-500/10" }, + maintenance_resolved: { icon: ClipboardCheck, color: "text-emerald-400", bg: "bg-emerald-500/10" }, + lease_created: { icon: FileText, color: "text-indigo-400", bg: "bg-indigo-500/10" }, + lease_expiring: { icon: AlertTriangle, color: "text-amber-400", bg: "bg-amber-500/10" }, + expense_added: { icon: Receipt, color: "text-purple-400", bg: "bg-purple-500/10" }, + property_added: { icon: Building2, color: "text-cyan-400", bg: "bg-cyan-500/10" }, + inspection_completed: { icon: ClipboardCheck, color: "text-teal-400", bg: "bg-teal-500/10" }, + vendor_added: { icon: Users, color: "text-pink-400", bg: "bg-pink-500/10" }, + ai_action: { icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10" }, +} + +const FILTER_OPTIONS = [ + { label: "All", value: "" }, + { label: "Rent", value: "rent" }, + { label: "Tenants", value: "tenant" }, + { label: "Maintenance", value: "maintenance" }, + { label: "Leases", value: "lease" }, + { label: "AI", value: "ai" }, +] + +export function ActivityFeed({ activities }: { activities: any[] }) { + const [filter, setFilter] = useState("") + + const filtered = filter + ? activities.filter((a) => a.type.startsWith(filter)) + : activities + + return ( +
+ {/* Header */} +
+
+

Activity Feed

+

{filtered.length} events

+
+ +
+ + {/* Filters */} +
+ {FILTER_OPTIONS.map((f) => ( + + ))} +
+ + {/* Feed */} + {filtered.length === 0 ? ( +
+ +

No activity yet

+

Actions like adding tenants, recording payments, and maintenance requests will appear here

+
+ ) : ( +
+ {filtered.map((activity) => { + const cfg = typeConfig[activity.type] ?? { icon: Activity, color: "text-white/40", bg: "bg-white/5" } + const Icon = cfg.icon + return ( +
+
+ +
+
+

{activity.title}

+ {activity.description && ( +

{activity.description}

+ )} +
+

+ {formatDistanceToNow(new Date(activity.created_at), { addSuffix: true })} +

+
+ ) + })} +
+ )} +
+ ) +} diff --git a/app/(dashboard)/activity/page.tsx b/app/(dashboard)/activity/page.tsx new file mode 100644 index 0000000..12797da --- /dev/null +++ b/app/(dashboard)/activity/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation" +import { desc, eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { activity_log } from "@/lib/db/schema" +import { getSessionUser } from "@/lib/session" +import { ActivityFeed } from "./activity-feed" + +export const metadata = { title: "Activity" } + +export default async function ActivityPage() { + const user = await getSessionUser() + if (!user) redirect("/login") + + const activities = await db + .select() + .from(activity_log) + .where(eq(activity_log.user_id, user.id)) + .orderBy(desc(activity_log.created_at)) + .limit(100) + + return +} diff --git a/app/(dashboard)/ai-dashboard/ai-dashboard-client.tsx b/app/(dashboard)/ai-dashboard/ai-dashboard-client.tsx new file mode 100644 index 0000000..d0f8c28 --- /dev/null +++ b/app/(dashboard)/ai-dashboard/ai-dashboard-client.tsx @@ -0,0 +1,216 @@ +"use client" + +import Link from "next/link" +import { formatDistanceToNow } from "date-fns" +import { + Zap, BarChart3, Sparkles, Activity, Bot, + TrendingUp, ShieldAlert, Wrench, ArrowRight, + CheckCircle, AlertTriangle, Brain, +} from "lucide-react" +import { formatCurrency } from "@/lib/utils" + +const riskBadge: Record = { + critical: "text-red-400 bg-red-500/10 ring-red-500/20", + high: "text-orange-400 bg-orange-500/10 ring-orange-500/20", + medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20", + low: "text-emerald-400 bg-emerald-500/10 ring-emerald-500/20", +} + +const priorityBadge: Record = { + high: "text-red-400 bg-red-500/10 ring-red-500/20", + medium: "text-amber-400 bg-amber-500/10 ring-amber-500/20", + low: "text-white/40 bg-white/5 ring-white/10", +} + +interface Props { + recentRecs: any[] + recentPredictions: any[] + activityLog: any[] + stats: { + totalImpact: number + approvedRecs: number + pendingRecs: number + occupancyRate: number + totalRevenue: number + overdueAmount: number + criticalMaintenance: number + riskAlerts: number + } +} + +export function AiDashboardClient({ recentRecs, recentPredictions, activityLog, stats }: Props) { + const hasAlerts = stats.riskAlerts > 0 || stats.criticalMaintenance > 0 || stats.overdueAmount > 0 + + return ( +
+ {/* Header */} +
+
+ +
+
+

AI Dashboard

+

Your portfolio intelligence at a glance

+
+
+ + {/* Alert banner */} + {hasAlerts && ( +
+ +
+ {stats.riskAlerts > 0 &&

{stats.riskAlerts} active risk alert{stats.riskAlerts > 1 ? "s" : ""} in your portfolio

} + {stats.criticalMaintenance > 0 &&

{stats.criticalMaintenance} high-priority maintenance request{stats.criticalMaintenance > 1 ? "s" : ""} open

} + {stats.overdueAmount > 0 &&

{formatCurrency(stats.overdueAmount)} in overdue rent

} +
+ + View + +
+ )} + + {/* Impact stats */} +
+ {[ + { label: "AI Impact", value: formatCurrency(stats.totalImpact), sub: "est. monthly value", color: "text-violet-400", icon: Sparkles }, + { label: "Approved", value: stats.approvedRecs, sub: "recommendations", color: "text-emerald-400", icon: CheckCircle }, + { label: "Pending Review", value: stats.pendingRecs, sub: "recommendations", color: "text-amber-400", icon: Zap }, + { label: "Risk Alerts", value: stats.riskAlerts, sub: "active", color: "text-red-400", icon: ShieldAlert }, + ].map((s) => { + const Icon = s.icon + return ( +
+
+ + {s.label} +
+

{s.value}

+

{s.sub}

+
+ ) + })} +
+ + {/* Quick links */} +
+ {[ + { label: "AI Assistant", href: "/ai", icon: Bot, color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/20" }, + { label: "AI Insights", href: "/recommendations", icon: Zap, color: "text-violet-400", bg: "bg-violet-500/10", border: "border-violet-500/20" }, + { label: "Predictions", href: "/predictions", icon: BarChart3, color: "text-indigo-400", bg: "bg-indigo-500/10", border: "border-indigo-500/20" }, + { label: "Impact Tracking", href: "/impact", icon: TrendingUp,color: "text-emerald-400",bg: "bg-emerald-500/10",border: "border-emerald-500/20"}, + ].map((item) => { + const Icon = item.icon + return ( + +
+ + {item.label} +
+ + + ) + })} +
+ +
+ {/* Recent recommendations */} +
+
+

Recent Recommendations

+ + View all + +
+ {recentRecs.length === 0 ? ( +
+

No recommendations yet

+ Generate now +
+ ) : ( +
+ {recentRecs.map((r) => ( +
+ +
+

{r.title}

+
+ + {r.priority} + + + {r.status} + +
+
+
+ ))} +
+ )} +
+ + {/* Recent predictions */} +
+
+

Recent Predictions

+ + View all + +
+ {recentPredictions.length === 0 ? ( +
+

No predictions yet

+ Run analysis +
+ ) : ( +
+ {recentPredictions.map((p) => ( +
+ +
+

{p.title}

+
+ + {p.risk_level} + + {p.timeframe} +
+
+
+ ))} +
+ )} +
+
+ + {/* AI Activity log */} + {activityLog.length > 0 && ( +
+
+

+ + Recent AI Actions +

+ + View all + +
+
+ {activityLog.map((a) => ( +
+ +

{a.title}

+ + {formatDistanceToNow(new Date(a.created_at), { addSuffix: true })} + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/app/(dashboard)/ai-dashboard/page.tsx b/app/(dashboard)/ai-dashboard/page.tsx new file mode 100644 index 0000000..17d2351 --- /dev/null +++ b/app/(dashboard)/ai-dashboard/page.tsx @@ -0,0 +1,104 @@ +import { redirect } from "next/navigation" +import { and, desc, eq, gte, inArray } from "drizzle-orm" +import { db } from "@/lib/db" +import { + ai_recommendations, + ai_predictions, + activity_log, + rent_payments, + units as unitsTable, + maintenance_requests, +} from "@/lib/db/schema" +import { getSessionUser } from "@/lib/session" +import { AiDashboardClient } from "./ai-dashboard-client" + +export const metadata = { title: "AI Dashboard" } + +export default async function AiDashboardPage() { + const user = await getSessionUser() + if (!user) redirect("/login") + + const now = new Date() + const threeMonthsAgo = new Date(now) + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3) + + const [recs, predictions, activityLog, payments, units, maintenance] = await Promise.all([ + db + .select() + .from(ai_recommendations) + .where(eq(ai_recommendations.user_id, user.id)) + .orderBy(desc(ai_recommendations.created_at)) + .limit(3), + db + .select() + .from(ai_predictions) + .where(eq(ai_predictions.user_id, user.id)) + .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"))) + .orderBy(desc(activity_log.created_at)) + .limit(5), + db + .select({ amount: rent_payments.amount, status: rent_payments.status }) + .from(rent_payments) + .where( + and( + eq(rent_payments.user_id, user.id), + gte(rent_payments.due_date, threeMonthsAgo.toISOString().slice(0, 10)) + ) + ), + db + .select({ status: unitsTable.status }) + .from(unitsTable) + .where(eq(unitsTable.user_id, user.id)), + db + .select({ status: maintenance_requests.status, priority: maintenance_requests.priority }) + .from(maintenance_requests) + .where( + and( + eq(maintenance_requests.user_id, user.id), + inArray(maintenance_requests.status, ["open", "in_progress"]) + ) + ), + ]) + + 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)) + const approvedRecs = allRecsData.filter((r) => r.status === "approved") + + let totalImpact = 0 + for (const r of approvedRecs) { + totalImpact += Number(r.action_data?.estimated_value ?? 0) + } + + const occupiedUnits = units?.filter((u: any) => u.status === "occupied").length ?? 0 + const totalUnits = units?.length ?? 0 + const occupancyRate = totalUnits > 0 ? Math.round((occupiedUnits / totalUnits) * 100) : 0 + const totalRevenue = payments?.filter((p: any) => p.status === "paid").reduce((s: number, p: any) => s + Number(p.amount), 0) ?? 0 + const overdueAmount = payments?.filter((p: any) => p.status === "overdue").reduce((s: number, p: any) => s + Number(p.amount), 0) ?? 0 + const criticalMaintenance = maintenance?.filter((m: any) => m.priority === "emergency" || m.priority === "high").length ?? 0 + const riskAlerts = predictions?.filter((p: any) => ["critical", "high"].includes(p.risk_level)).length ?? 0 + + return ( + r.status === "pending").length, + occupancyRate, + totalRevenue, + overdueAmount, + criticalMaintenance, + riskAlerts, + }} + /> + ) +} diff --git a/app/(dashboard)/ai/ai-chat.tsx b/app/(dashboard)/ai/ai-chat.tsx new file mode 100644 index 0000000..c368f7d --- /dev/null +++ b/app/(dashboard)/ai/ai-chat.tsx @@ -0,0 +1,307 @@ +"use client" + +import { useState, useRef, useEffect } from "react" +import { Send, Bot, Sparkles, Lock, Loader2, RotateCcw, Copy, Check, Zap } from "lucide-react" +import Link from "next/link" +import type { Plan } from "@/types" + +interface Message { + role: "user" | "assistant" + content: string + error?: boolean +} + +const SUGGESTED = [ + "Which tenants have overdue rent this month?", + "Summarise my open maintenance requests", + "How is my occupancy rate?", + "Which leases are expiring in 60 days?", + "What were my total expenses this quarter?", + "Which property earns the most rent?", +] + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +function MessageBubble({ msg }: { msg: Message }) { + if (msg.role === "user") { + return ( +
+
+

{msg.content}

+
+
+ ) + } + + return ( +
+
+ +
+
+
+

{msg.content}

+
+ {!msg.error && ( +
+ +
+ )} +
+
+ ) +} + +interface AiChatProps { + plan: Plan + limit: number + used: number +} + +export function AiChat({ plan, limit, used }: AiChatProps) { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState("") + const [loading, setLoading] = useState(false) + const [currentUsed, setCurrentUsed] = useState(used) + const bottomRef = useRef(null) + const inputRef = useRef(null) + const isLocked = limit === 0 + const isExhausted = !isLocked && currentUsed >= limit + const usagePct = limit > 0 ? Math.min((currentUsed / limit) * 100, 100) : 0 + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages, loading]) + + async function send(question: string) { + if (!question.trim() || loading || isLocked || isExhausted) return + + setMessages((prev) => [...prev, { role: "user", content: question }]) + setInput("") + setLoading(true) + + // Reset textarea height + if (inputRef.current) { + inputRef.current.style.height = "auto" + } + + try { + const res = await fetch("/api/ai/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ question }), + }) + const data = await res.json() + + if (!res.ok) { + setMessages((prev) => [...prev, { role: "assistant", content: data.error ?? "Something went wrong.", error: true }]) + } else { + setMessages((prev) => [...prev, { role: "assistant", content: data.answer }]) + if (data.usage) setCurrentUsed(data.usage.used) + } + } catch { + setMessages((prev) => [...prev, { role: "assistant", content: "Network error. Please try again.", error: true }]) + } finally { + setLoading(false) + } + } + + function handleKey(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + send(input) + } + } + + return ( +
+ + {/* Header */} +
+
+
+ +
+
+

AI Assistant

+

Powered by your live portfolio data

+
+
+ +
+ {!isLocked && ( +
+
+ + + {currentUsed} / {limit} + +
+
+
75 ? "bg-amber-500" : "bg-indigo-500"}`} + style={{ width: `${usagePct}%` }} + /> +
+
+ )} + {messages.length > 0 && ( + + )} +
+
+ + {/* Locked state */} + {isLocked ? ( +
+
+
+
+
+ +
+

AI requires Pro plan

+

+ Upgrade to unlock AI-powered insights about your properties, tenants, rent collection, and more. +

+
+ + + Upgrade to Pro — 50 AI calls/mo + + + Testing? Switch plan in Demo Data → + +
+
+
+
+ ) : ( + <> + {/* Chat area */} +
+ {messages.length === 0 ? ( +
+
+
+
+
+ +
+
+

How can I help?

+

+ I have full access to your live portfolio — properties, tenants, payments, maintenance, and leases. +

+
+ +
+

Try asking

+
+ {SUGGESTED.map((q) => ( + + ))} +
+
+
+ ) : ( + <> + {messages.map((msg, i) => ( + + ))} + {loading && ( +
+
+ +
+
+
+ + Analysing your portfolio… +
+
+
+ )} +
+ + )} +
+ + {/* Input */} +
+ {isExhausted && ( +
+ Monthly limit reached. Upgrade for more calls → +
+ )} +
+