From 857b9a78112553e4875eab18ae3518e8a82e1a5a Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:36:07 -0400 Subject: [PATCH] Initial import: property management SaaS + security hardening + admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dockerignore | 35 + .env.example | 48 + .env.production.example | 62 + .gitattributes | 4 + .gitignore | 55 + COOLIFY.md | 127 + Dockerfile | 66 + README.md | 229 + SECURITY.md | 77 + app/(admin)/admin/activity/page.tsx | 172 + app/(admin)/admin/ai-usage/page.tsx | 112 + app/(admin)/admin/billing/page.tsx | 183 + app/(admin)/admin/layout.tsx | 28 + app/(admin)/admin/page.tsx | 142 + app/(admin)/admin/system/page.tsx | 107 + app/(admin)/admin/users/[id]/page.tsx | 215 + app/(admin)/admin/users/page.tsx | 33 + app/(auth)/forgot-password/page.tsx | 69 + app/(auth)/layout.tsx | 13 + app/(auth)/login/page.tsx | 132 + app/(auth)/signup/page.tsx | 156 + app/(auth)/update-password/page.tsx | 62 + app/(dashboard)/activity/activity-feed.tsx | 103 + app/(dashboard)/activity/page.tsx | 22 + .../ai-dashboard/ai-dashboard-client.tsx | 216 + app/(dashboard)/ai-dashboard/page.tsx | 104 + app/(dashboard)/ai/ai-chat.tsx | 307 + app/(dashboard)/ai/loading.tsx | 14 + app/(dashboard)/ai/page.tsx | 41 + app/(dashboard)/calendar/calendar-client.tsx | 223 + app/(dashboard)/calendar/loading.tsx | 36 + app/(dashboard)/calendar/page.tsx | 52 + app/(dashboard)/dashboard/loading.tsx | 5 + app/(dashboard)/dashboard/page.tsx | 317 + app/(dashboard)/error.tsx | 35 + app/(dashboard)/expenses/expenses-client.tsx | 176 + app/(dashboard)/expenses/loading.tsx | 5 + app/(dashboard)/expenses/new/page.tsx | 34 + app/(dashboard)/expenses/page.tsx | 36 + .../follow-ups/follow-ups-client.tsx | 239 + app/(dashboard)/follow-ups/page.tsx | 29 + app/(dashboard)/impact/impact-client.tsx | 161 + app/(dashboard)/impact/page.tsx | 63 + .../inspections/inspection-manager.tsx | 184 + app/(dashboard)/inspections/loading.tsx | 2 + app/(dashboard)/inspections/page.tsx | 41 + app/(dashboard)/layout.tsx | 48 + app/(dashboard)/leases/loading.tsx | 5 + app/(dashboard)/leases/new/page.tsx | 57 + app/(dashboard)/leases/page.tsx | 176 + .../maintenance/[requestId]/page.tsx | 110 + app/(dashboard)/maintenance/loading.tsx | 5 + .../maintenance/maintenance-list.tsx | 119 + app/(dashboard)/maintenance/new/page.tsx | 45 + app/(dashboard)/maintenance/page.tsx | 47 + app/(dashboard)/predictions/page.tsx | 21 + .../predictions/predictions-client.tsx | 212 + .../[propertyId]/documents/page.tsx | 116 + .../properties/[propertyId]/edit/page.tsx | 28 + .../properties/[propertyId]/page.tsx | 201 + .../[propertyId]/units/new/page.tsx | 36 + app/(dashboard)/properties/loading.tsx | 5 + app/(dashboard)/properties/new/page.tsx | 22 + app/(dashboard)/properties/page.tsx | 138 + app/(dashboard)/recommendations/page.tsx | 21 + .../recommendations-client.tsx | 192 + .../rent/generate/bulk-generate-form.tsx | 156 + app/(dashboard)/rent/generate/page.tsx | 35 + app/(dashboard)/rent/import/page.tsx | 39 + .../rent/import/rent-csv-import.tsx | 194 + app/(dashboard)/rent/loading.tsx | 5 + app/(dashboard)/rent/new/page.tsx | 35 + app/(dashboard)/rent/page.tsx | 53 + app/(dashboard)/rent/rent-table.tsx | 285 + app/(dashboard)/reports/loading.tsx | 2 + app/(dashboard)/reports/page.tsx | 107 + app/(dashboard)/reports/reports-client.tsx | 138 + app/(dashboard)/settings/billing/page.tsx | 240 + app/(dashboard)/settings/demo/page.tsx | 134 + app/(dashboard)/settings/loading.tsx | 18 + app/(dashboard)/settings/page.tsx | 5 + app/(dashboard)/settings/profile/page.tsx | 27 + .../tenants/[tenantId]/edit/page.tsx | 43 + app/(dashboard)/tenants/[tenantId]/page.tsx | 186 + app/(dashboard)/tenants/loading.tsx | 5 + app/(dashboard)/tenants/new/page.tsx | 34 + app/(dashboard)/tenants/page.tsx | 57 + app/(dashboard)/tenants/tenants-table.tsx | 168 + app/(dashboard)/vendors/loading.tsx | 2 + app/(dashboard)/vendors/page.tsx | 35 + app/(dashboard)/vendors/vendor-manager.tsx | 211 + app/(marketing)/api-docs/page.tsx | 119 + app/(marketing)/cookie-policy/page.tsx | 77 + app/(marketing)/gdpr/page.tsx | 145 + app/(marketing)/layout.tsx | 12 + app/(marketing)/page.tsx | 30 + app/(marketing)/privacy/page.tsx | 17 + app/(marketing)/status/page.tsx | 118 + app/(marketing)/tenant-portal-info/page.tsx | 112 + app/(marketing)/terms/page.tsx | 17 + app/actions/admin.ts | 139 + app/actions/auth.ts | 105 + app/actions/seed-demo.ts | 401 + app/api/activity/route.ts | 26 + app/api/admin/export/billing/route.ts | 30 + app/api/admin/export/users/route.ts | 33 + app/api/admin/users/route.ts | 21 + app/api/ai/ask/route.ts | 166 + app/api/ai/impact/route.ts | 56 + app/api/ai/maintenance-summary/route.ts | 61 + app/api/ai/predictions/route.ts | 209 + app/api/ai/recommendations/[id]/route.ts | 42 + app/api/ai/recommendations/route.ts | 202 + app/api/ai/rent-receipt/route.ts | 61 + app/api/auth/[...all]/route.ts | 4 + app/api/cron/daily/route.ts | 141 + app/api/cron/late-fees/route.ts | 69 + app/api/cron/lease-expiry/route.ts | 67 + app/api/cron/rent-reminders/route.ts | 79 + app/api/documents/[id]/route.ts | 44 + app/api/documents/route.ts | 85 + app/api/expenses/[id]/route.ts | 41 + app/api/expenses/export/route.ts | 48 + app/api/expenses/route.ts | 54 + app/api/export/rent/route.ts | 46 + app/api/export/tenants/route.ts | 44 + app/api/files/[...key]/route.ts | 41 + app/api/follow-ups/[id]/route.ts | 40 + app/api/follow-ups/route.ts | 43 + app/api/follow-ups/run/route.ts | 198 + app/api/health/route.ts | 14 + app/api/inspections/[id]/route.ts | 61 + app/api/inspections/route.ts | 73 + app/api/leases/[id]/route.ts | 42 + app/api/leases/route.ts | 56 + app/api/maintenance/[id]/route.ts | 79 + app/api/maintenance/route.ts | 103 + app/api/notifications/read/route.ts | 18 + app/api/notifications/route.ts | 102 + app/api/profile/route.ts | 37 + app/api/properties/[id]/route.ts | 55 + app/api/properties/route.ts | 52 + app/api/rent/[id]/route.ts | 59 + app/api/rent/generate/route.ts | 63 + app/api/rent/payment-link/route.ts | 41 + app/api/rent/route.ts | 66 + app/api/rent/send-payment-link/route.ts | 87 + app/api/search/route.ts | 65 + app/api/stripe/checkout/route.ts | 61 + app/api/stripe/portal/route.ts | 27 + app/api/stripe/webhook/route.ts | 113 + .../tenants/[id]/rotate-portal-token/route.ts | 22 + app/api/tenants/[id]/route.ts | 84 + app/api/tenants/route.ts | 90 + app/api/units/[id]/route.ts | 46 + app/api/units/route.ts | 48 + app/api/upload/route.ts | 55 + app/api/vendors/[id]/route.ts | 46 + app/api/vendors/route.ts | 54 + app/global-error.tsx | 30 + app/globals.css | 128 + app/icon.svg | 18 + app/layout.tsx | 48 + app/not-found.tsx | 16 + app/page.tsx | 48 + app/robots.txt/route.ts | 20 + app/sitemap.ts | 13 + app/tenant-portal/[token]/page.tsx | 203 + .../[token]/tenant-maintenance-form.tsx | 106 + components/admin/admin-charts.tsx | 159 + components/admin/admin-header.tsx | 43 + components/admin/admin-sidebar.tsx | 92 + components/admin/impersonation-banner.tsx | 40 + components/admin/user-actions.tsx | 265 + components/admin/users-table.tsx | 271 + components/dashboard/breadcrumbs.tsx | 74 + components/dashboard/command-palette.tsx | 364 + .../dashboard/expense-breakdown-chart.tsx | 61 + components/dashboard/header.tsx | 73 + .../dashboard/maintenance-status-badge.tsx | 36 + components/dashboard/notifications-bell.tsx | 145 + components/dashboard/occupancy-ring.tsx | 47 + components/dashboard/page-transition.tsx | 23 + .../dashboard/property-revenue-chart.tsx | 74 + components/dashboard/quick-actions.tsx | 34 + components/dashboard/rent-status-badge.tsx | 20 + components/dashboard/revenue-chart.tsx | 97 + components/dashboard/sidebar.tsx | 307 + components/dashboard/stats-card.tsx | 146 + components/forms/ai-maintenance-summary.tsx | 173 + components/forms/checkout-button.tsx | 35 + components/forms/csv-export-button.tsx | 46 + components/forms/delete-property-button.tsx | 48 + components/forms/expense-form.tsx | 162 + components/forms/late-notice-button.tsx | 138 + components/forms/lease-form.tsx | 172 + components/forms/maintenance-form.tsx | 221 + .../forms/maintenance-status-updater.tsx | 90 + components/forms/portal-button.tsx | 25 + components/forms/profile-form.tsx | 81 + components/forms/property-form.tsx | 141 + components/forms/property-photo-upload.tsx | 133 + components/forms/rent-actions.tsx | 53 + components/forms/rent-payment-form.tsx | 150 + components/forms/rent-receipt-button.tsx | 154 + components/forms/tenant-form.tsx | 161 + components/forms/unit-form.tsx | 127 + components/marketing/cta-banner.tsx | 70 + components/marketing/cursor-glow.tsx | 41 + components/marketing/faq.tsx | 101 + components/marketing/features.tsx | 144 + components/marketing/floating-cta.tsx | 52 + components/marketing/footer.tsx | 79 + components/marketing/hero.tsx | 306 + components/marketing/how-it-works.tsx | 97 + components/marketing/integrations.tsx | 105 + components/marketing/marquee.tsx | 39 + components/marketing/navbar.tsx | 104 + components/marketing/pricing-section.tsx | 273 + components/marketing/pricing.tsx | 154 + components/marketing/problem.tsx | 99 + components/marketing/scroll-progress.tsx | 16 + components/marketing/social-proof-toast.tsx | 68 + components/marketing/testimonials.tsx | 119 + components/shared/copy-button.tsx | 24 + components/shared/delete-button.tsx | 62 + components/shared/empty-state.tsx | 34 + components/shared/file-upload.tsx | 69 + components/shared/logo.tsx | 64 + components/shared/send-reminder-button.tsx | 33 + components/shared/skeleton.tsx | 141 + components/shared/upgrade-modal.tsx | 64 + components/ui/animated-number.tsx | 57 + components/ui/back-button.tsx | 28 + components/ui/confirm-modal.tsx | 107 + components/ui/highlight-row.tsx | 42 + components/ui/scroll-to-top.tsx | 35 + components/ui/select.tsx | 175 + docker-compose.yml | 74 + docker-entrypoint.sh | 15 + drizzle.config.ts | 16 + eslint.config.mjs | 18 + lib/activity.ts | 49 + lib/admin/audit.ts | 42 + lib/ai/client.ts | 23 + lib/ai/prompts.ts | 50 + lib/ai/usage.ts | 83 + lib/auth-client.ts | 11 + lib/auth.ts | 90 + lib/cron-auth.ts | 13 + lib/db/admin-queries.ts | 409 + lib/db/index.ts | 53 + lib/db/migrations/0000_next_red_skull.sql | 373 + lib/db/migrations/meta/0000_snapshot.json | 2548 ++++ lib/db/migrations/meta/_journal.json | 13 + lib/db/ownership.ts | 31 + lib/db/queries.ts | 220 + lib/db/schema.ts | 589 + lib/email/client.ts | 9 + lib/email/send.ts | 171 + lib/hooks/use-user.ts | 54 + lib/hooks/use-warn-unsaved.ts | 18 + lib/session.ts | 71 + lib/storage.ts | 76 + lib/stripe/client.ts | 27 + lib/stripe/payment-links.ts | 46 + lib/stripe/plans.ts | 77 + lib/utils.ts | 64 + lib/validations/index.ts | 120 + next.config.ts | 48 + package-lock.json | 11348 ++++++++++++++++ package.json | 66 + postcss.config.mjs | 7 + proxy.ts | 58 + public/file.svg | 1 + public/globe.svg | 1 + public/logo-dark.svg | 22 + public/logo-light.svg | 22 + public/logo-mark.svg | 18 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + scripts/migrate-admin.ts | 58 + scripts/migrate.mjs | 62 + scripts/seed-admin.ts | 64 + scripts/seed-users.ts | 288 + scripts/verify-admin.ts | 50 + supabase/google-oauth-setup.md | 32 + tsconfig.json | 34 + types/index.ts | 84 + vercel.json | 12 + 291 files changed, 38996 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.production.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 COOLIFY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 app/(admin)/admin/activity/page.tsx create mode 100644 app/(admin)/admin/ai-usage/page.tsx create mode 100644 app/(admin)/admin/billing/page.tsx create mode 100644 app/(admin)/admin/layout.tsx create mode 100644 app/(admin)/admin/page.tsx create mode 100644 app/(admin)/admin/system/page.tsx create mode 100644 app/(admin)/admin/users/[id]/page.tsx create mode 100644 app/(admin)/admin/users/page.tsx create mode 100644 app/(auth)/forgot-password/page.tsx create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/signup/page.tsx create mode 100644 app/(auth)/update-password/page.tsx create mode 100644 app/(dashboard)/activity/activity-feed.tsx create mode 100644 app/(dashboard)/activity/page.tsx create mode 100644 app/(dashboard)/ai-dashboard/ai-dashboard-client.tsx create mode 100644 app/(dashboard)/ai-dashboard/page.tsx create mode 100644 app/(dashboard)/ai/ai-chat.tsx create mode 100644 app/(dashboard)/ai/loading.tsx create mode 100644 app/(dashboard)/ai/page.tsx create mode 100644 app/(dashboard)/calendar/calendar-client.tsx create mode 100644 app/(dashboard)/calendar/loading.tsx create mode 100644 app/(dashboard)/calendar/page.tsx create mode 100644 app/(dashboard)/dashboard/loading.tsx create mode 100644 app/(dashboard)/dashboard/page.tsx create mode 100644 app/(dashboard)/error.tsx create mode 100644 app/(dashboard)/expenses/expenses-client.tsx create mode 100644 app/(dashboard)/expenses/loading.tsx create mode 100644 app/(dashboard)/expenses/new/page.tsx create mode 100644 app/(dashboard)/expenses/page.tsx create mode 100644 app/(dashboard)/follow-ups/follow-ups-client.tsx create mode 100644 app/(dashboard)/follow-ups/page.tsx create mode 100644 app/(dashboard)/impact/impact-client.tsx create mode 100644 app/(dashboard)/impact/page.tsx create mode 100644 app/(dashboard)/inspections/inspection-manager.tsx create mode 100644 app/(dashboard)/inspections/loading.tsx create mode 100644 app/(dashboard)/inspections/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/leases/loading.tsx create mode 100644 app/(dashboard)/leases/new/page.tsx create mode 100644 app/(dashboard)/leases/page.tsx create mode 100644 app/(dashboard)/maintenance/[requestId]/page.tsx create mode 100644 app/(dashboard)/maintenance/loading.tsx create mode 100644 app/(dashboard)/maintenance/maintenance-list.tsx create mode 100644 app/(dashboard)/maintenance/new/page.tsx create mode 100644 app/(dashboard)/maintenance/page.tsx create mode 100644 app/(dashboard)/predictions/page.tsx create mode 100644 app/(dashboard)/predictions/predictions-client.tsx create mode 100644 app/(dashboard)/properties/[propertyId]/documents/page.tsx create mode 100644 app/(dashboard)/properties/[propertyId]/edit/page.tsx create mode 100644 app/(dashboard)/properties/[propertyId]/page.tsx create mode 100644 app/(dashboard)/properties/[propertyId]/units/new/page.tsx create mode 100644 app/(dashboard)/properties/loading.tsx create mode 100644 app/(dashboard)/properties/new/page.tsx create mode 100644 app/(dashboard)/properties/page.tsx create mode 100644 app/(dashboard)/recommendations/page.tsx create mode 100644 app/(dashboard)/recommendations/recommendations-client.tsx create mode 100644 app/(dashboard)/rent/generate/bulk-generate-form.tsx create mode 100644 app/(dashboard)/rent/generate/page.tsx create mode 100644 app/(dashboard)/rent/import/page.tsx create mode 100644 app/(dashboard)/rent/import/rent-csv-import.tsx create mode 100644 app/(dashboard)/rent/loading.tsx create mode 100644 app/(dashboard)/rent/new/page.tsx create mode 100644 app/(dashboard)/rent/page.tsx create mode 100644 app/(dashboard)/rent/rent-table.tsx create mode 100644 app/(dashboard)/reports/loading.tsx create mode 100644 app/(dashboard)/reports/page.tsx create mode 100644 app/(dashboard)/reports/reports-client.tsx create mode 100644 app/(dashboard)/settings/billing/page.tsx create mode 100644 app/(dashboard)/settings/demo/page.tsx create mode 100644 app/(dashboard)/settings/loading.tsx create mode 100644 app/(dashboard)/settings/page.tsx create mode 100644 app/(dashboard)/settings/profile/page.tsx create mode 100644 app/(dashboard)/tenants/[tenantId]/edit/page.tsx create mode 100644 app/(dashboard)/tenants/[tenantId]/page.tsx create mode 100644 app/(dashboard)/tenants/loading.tsx create mode 100644 app/(dashboard)/tenants/new/page.tsx create mode 100644 app/(dashboard)/tenants/page.tsx create mode 100644 app/(dashboard)/tenants/tenants-table.tsx create mode 100644 app/(dashboard)/vendors/loading.tsx create mode 100644 app/(dashboard)/vendors/page.tsx create mode 100644 app/(dashboard)/vendors/vendor-manager.tsx create mode 100644 app/(marketing)/api-docs/page.tsx create mode 100644 app/(marketing)/cookie-policy/page.tsx create mode 100644 app/(marketing)/gdpr/page.tsx create mode 100644 app/(marketing)/layout.tsx create mode 100644 app/(marketing)/page.tsx create mode 100644 app/(marketing)/privacy/page.tsx create mode 100644 app/(marketing)/status/page.tsx create mode 100644 app/(marketing)/tenant-portal-info/page.tsx create mode 100644 app/(marketing)/terms/page.tsx create mode 100644 app/actions/admin.ts create mode 100644 app/actions/auth.ts create mode 100644 app/actions/seed-demo.ts create mode 100644 app/api/activity/route.ts create mode 100644 app/api/admin/export/billing/route.ts create mode 100644 app/api/admin/export/users/route.ts create mode 100644 app/api/admin/users/route.ts create mode 100644 app/api/ai/ask/route.ts create mode 100644 app/api/ai/impact/route.ts create mode 100644 app/api/ai/maintenance-summary/route.ts create mode 100644 app/api/ai/predictions/route.ts create mode 100644 app/api/ai/recommendations/[id]/route.ts create mode 100644 app/api/ai/recommendations/route.ts create mode 100644 app/api/ai/rent-receipt/route.ts create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/cron/daily/route.ts create mode 100644 app/api/cron/late-fees/route.ts create mode 100644 app/api/cron/lease-expiry/route.ts create mode 100644 app/api/cron/rent-reminders/route.ts create mode 100644 app/api/documents/[id]/route.ts create mode 100644 app/api/documents/route.ts create mode 100644 app/api/expenses/[id]/route.ts create mode 100644 app/api/expenses/export/route.ts create mode 100644 app/api/expenses/route.ts create mode 100644 app/api/export/rent/route.ts create mode 100644 app/api/export/tenants/route.ts create mode 100644 app/api/files/[...key]/route.ts create mode 100644 app/api/follow-ups/[id]/route.ts create mode 100644 app/api/follow-ups/route.ts create mode 100644 app/api/follow-ups/run/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/inspections/[id]/route.ts create mode 100644 app/api/inspections/route.ts create mode 100644 app/api/leases/[id]/route.ts create mode 100644 app/api/leases/route.ts create mode 100644 app/api/maintenance/[id]/route.ts create mode 100644 app/api/maintenance/route.ts create mode 100644 app/api/notifications/read/route.ts create mode 100644 app/api/notifications/route.ts create mode 100644 app/api/profile/route.ts create mode 100644 app/api/properties/[id]/route.ts create mode 100644 app/api/properties/route.ts create mode 100644 app/api/rent/[id]/route.ts create mode 100644 app/api/rent/generate/route.ts create mode 100644 app/api/rent/payment-link/route.ts create mode 100644 app/api/rent/route.ts create mode 100644 app/api/rent/send-payment-link/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/api/stripe/checkout/route.ts create mode 100644 app/api/stripe/portal/route.ts create mode 100644 app/api/stripe/webhook/route.ts create mode 100644 app/api/tenants/[id]/rotate-portal-token/route.ts create mode 100644 app/api/tenants/[id]/route.ts create mode 100644 app/api/tenants/route.ts create mode 100644 app/api/units/[id]/route.ts create mode 100644 app/api/units/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 app/api/vendors/[id]/route.ts create mode 100644 app/api/vendors/route.ts create mode 100644 app/global-error.tsx create mode 100644 app/globals.css create mode 100644 app/icon.svg create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/robots.txt/route.ts create mode 100644 app/sitemap.ts create mode 100644 app/tenant-portal/[token]/page.tsx create mode 100644 app/tenant-portal/[token]/tenant-maintenance-form.tsx create mode 100644 components/admin/admin-charts.tsx create mode 100644 components/admin/admin-header.tsx create mode 100644 components/admin/admin-sidebar.tsx create mode 100644 components/admin/impersonation-banner.tsx create mode 100644 components/admin/user-actions.tsx create mode 100644 components/admin/users-table.tsx create mode 100644 components/dashboard/breadcrumbs.tsx create mode 100644 components/dashboard/command-palette.tsx create mode 100644 components/dashboard/expense-breakdown-chart.tsx create mode 100644 components/dashboard/header.tsx create mode 100644 components/dashboard/maintenance-status-badge.tsx create mode 100644 components/dashboard/notifications-bell.tsx create mode 100644 components/dashboard/occupancy-ring.tsx create mode 100644 components/dashboard/page-transition.tsx create mode 100644 components/dashboard/property-revenue-chart.tsx create mode 100644 components/dashboard/quick-actions.tsx create mode 100644 components/dashboard/rent-status-badge.tsx create mode 100644 components/dashboard/revenue-chart.tsx create mode 100644 components/dashboard/sidebar.tsx create mode 100644 components/dashboard/stats-card.tsx create mode 100644 components/forms/ai-maintenance-summary.tsx create mode 100644 components/forms/checkout-button.tsx create mode 100644 components/forms/csv-export-button.tsx create mode 100644 components/forms/delete-property-button.tsx create mode 100644 components/forms/expense-form.tsx create mode 100644 components/forms/late-notice-button.tsx create mode 100644 components/forms/lease-form.tsx create mode 100644 components/forms/maintenance-form.tsx create mode 100644 components/forms/maintenance-status-updater.tsx create mode 100644 components/forms/portal-button.tsx create mode 100644 components/forms/profile-form.tsx create mode 100644 components/forms/property-form.tsx create mode 100644 components/forms/property-photo-upload.tsx create mode 100644 components/forms/rent-actions.tsx create mode 100644 components/forms/rent-payment-form.tsx create mode 100644 components/forms/rent-receipt-button.tsx create mode 100644 components/forms/tenant-form.tsx create mode 100644 components/forms/unit-form.tsx create mode 100644 components/marketing/cta-banner.tsx create mode 100644 components/marketing/cursor-glow.tsx create mode 100644 components/marketing/faq.tsx create mode 100644 components/marketing/features.tsx create mode 100644 components/marketing/floating-cta.tsx create mode 100644 components/marketing/footer.tsx create mode 100644 components/marketing/hero.tsx create mode 100644 components/marketing/how-it-works.tsx create mode 100644 components/marketing/integrations.tsx create mode 100644 components/marketing/marquee.tsx create mode 100644 components/marketing/navbar.tsx create mode 100644 components/marketing/pricing-section.tsx create mode 100644 components/marketing/pricing.tsx create mode 100644 components/marketing/problem.tsx create mode 100644 components/marketing/scroll-progress.tsx create mode 100644 components/marketing/social-proof-toast.tsx create mode 100644 components/marketing/testimonials.tsx create mode 100644 components/shared/copy-button.tsx create mode 100644 components/shared/delete-button.tsx create mode 100644 components/shared/empty-state.tsx create mode 100644 components/shared/file-upload.tsx create mode 100644 components/shared/logo.tsx create mode 100644 components/shared/send-reminder-button.tsx create mode 100644 components/shared/skeleton.tsx create mode 100644 components/shared/upgrade-modal.tsx create mode 100644 components/ui/animated-number.tsx create mode 100644 components/ui/back-button.tsx create mode 100644 components/ui/confirm-modal.tsx create mode 100644 components/ui/highlight-row.tsx create mode 100644 components/ui/scroll-to-top.tsx create mode 100644 components/ui/select.tsx create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 drizzle.config.ts create mode 100644 eslint.config.mjs create mode 100644 lib/activity.ts create mode 100644 lib/admin/audit.ts create mode 100644 lib/ai/client.ts create mode 100644 lib/ai/prompts.ts create mode 100644 lib/ai/usage.ts create mode 100644 lib/auth-client.ts create mode 100644 lib/auth.ts create mode 100644 lib/cron-auth.ts create mode 100644 lib/db/admin-queries.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/migrations/0000_next_red_skull.sql create mode 100644 lib/db/migrations/meta/0000_snapshot.json create mode 100644 lib/db/migrations/meta/_journal.json create mode 100644 lib/db/ownership.ts create mode 100644 lib/db/queries.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/email/client.ts create mode 100644 lib/email/send.ts create mode 100644 lib/hooks/use-user.ts create mode 100644 lib/hooks/use-warn-unsaved.ts create mode 100644 lib/session.ts create mode 100644 lib/storage.ts create mode 100644 lib/stripe/client.ts create mode 100644 lib/stripe/payment-links.ts create mode 100644 lib/stripe/plans.ts create mode 100644 lib/utils.ts create mode 100644 lib/validations/index.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 proxy.ts create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/logo-dark.svg create mode 100644 public/logo-light.svg create mode 100644 public/logo-mark.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 scripts/migrate-admin.ts create mode 100644 scripts/migrate.mjs create mode 100644 scripts/seed-admin.ts create mode 100644 scripts/seed-users.ts create mode 100644 scripts/verify-admin.ts create mode 100644 supabase/google-oauth-setup.md create mode 100644 tsconfig.json create mode 100644 types/index.ts create mode 100644 vercel.json 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 → +
+ )} +
+