diff --git a/.env b/.env new file mode 100644 index 0000000..28f3f7f --- /dev/null +++ b/.env @@ -0,0 +1,42 @@ +# Optional overrides for compose.yaml +CLIENT_PORT=8081 +SERVER_PORT=5001 +DB_PORT=5433 +DB_NAME=apex_rentals + +# Required for auth flows beyond local smoke tests. +JWT_SECRET=local-dev-jwt-secret +JWT_EXPIRES_IN=7d + +# Postgres connection details. +DB_HOST=localhost +DB_USER=postgres +DB_PASSWORD=postgres + +# Optional third-party integrations. Leave blank for local-only runs. +GEMINI_API_KEY= +EMAIL_HOST= +EMAIL_USER= +EMAIL_PASS= +MPESA_CONSUMER_KEY= +MPESA_CONSUMER_SECRET= +MPESA_SHORTCODE= +MPESA_PASSKEY= +MPESA_CALLBACK_URL=http://localhost:5001/api/payments/callback + +# Optional super admin seed overrides. +SUPER_ADMIN_NAME=Apex Super Admin +SUPER_ADMIN_EMAIL=admin@apex.local +SUPER_ADMIN_PASSWORD=Admin123! + +# Optional starter landlord and tenant seed overrides. +TENANT_NAME=Kamau Tenant +TENANT_EMAIL=kamau1@gmail.com +LANDLORD_NAME=Kamau Landlord +LANDLORD_EMAIL=kamau3@gmail.com +USER_PASSWORD=123456789 +STARTER_PROPERTY_NAME=Kamau Heights +STARTER_PROPERTY_ADDRESS=Lumumba Drive, Nairobi +STARTER_OCCUPIED_UNIT=A1 +STARTER_VACANT_UNIT=A2 +STARTER_RENT_AMOUNT=25000 diff --git a/.env.example b/.env.example index 128818a..791aef3 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ # Optional overrides for compose.yaml CLIENT_PORT=8081 SERVER_PORT=5000 -MONGO_PORT=27017 -MONGO_DB=apex_rentals +DB_PORT=5432 +DB_NAME=apex_rentals # Required for auth flows beyond local smoke tests. JWT_SECRET=local-dev-jwt-secret +JWT_EXPIRES_IN=7d + +# Postgres connection details. +DB_HOST=localhost +DB_USER=postgres +DB_PASSWORD= # Optional third-party integrations. Leave blank for local-only runs. GEMINI_API_KEY= diff --git a/.gitignore b/.gitignore index 9143c8d..78a223d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ server/node_modules server/.env client/node_modules -client/vite.config.js \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore index 2f8ccca..f06235c 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1,3 +1,2 @@ node_modules dist -vite.config.js diff --git a/client/index.html b/client/index.html index 60726ff..4ecf799 100644 --- a/client/index.html +++ b/client/index.html @@ -3,8 +3,21 @@ + + Apex Agencies - Real Estate Management +
diff --git a/client/nginx.conf b/client/nginx.conf index 5968c48..c106795 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -6,7 +6,7 @@ server { index index.html; location /api/ { - proxy_pass http://server:5000; + proxy_pass http://host.docker.internal:5000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -15,7 +15,7 @@ server { } location /uploads/ { - proxy_pass http://server:5000; + proxy_pass http://host.docker.internal:5000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/client/package-lock.json b/client/package-lock.json index c06787d..efd9b31 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,6 +17,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.30.3", "socket.io-client": "^4.7.5", + "sonner": "^2.0.7", "sweetalert2": "^11.26.24" }, "devDependencies": { @@ -2080,6 +2081,16 @@ "node": ">=10.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/client/package.json b/client/package.json index badff05..7416031 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.30.3", "socket.io-client": "^4.7.5", + "sonner": "^2.0.7", "sweetalert2": "^11.26.24" }, "devDependencies": { diff --git a/client/public/icons/icon.svg b/client/public/icons/icon.svg new file mode 100644 index 0000000..7aa44ec --- /dev/null +++ b/client/public/icons/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/public/manifest.json b/client/public/manifest.json new file mode 100644 index 0000000..7ff68bf --- /dev/null +++ b/client/public/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Apex Rentals Management", + "short_name": "Apex", + "description": "Multi-tenant Rentals Management Solution", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#6366f1", + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/client/public/sw.js b/client/public/sw.js new file mode 100644 index 0000000..c603426 --- /dev/null +++ b/client/public/sw.js @@ -0,0 +1,19 @@ +const CACHE_NAME = 'apex-cache-v1'; +const urlsToCache = [ + '/', + '/index.html', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(urlsToCache)) + ); +}); + +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request) + .then((response) => response || fetch(event.request)) + ); +}); diff --git a/client/src/App.css b/client/src/App.css index 94d7c32..5280137 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,157 +1,456 @@ -.app-container { +.app-layout { display: flex; - flex-direction: column; min-height: 100vh; + width: 100%; + background: var(--bg-dark); } -.main-content { +.main-wrapper { flex: 1; - padding: 2rem; - max-width: 1400px; - margin: 0 auto; - width: 100%; + display: flex; + flex-direction: column; + min-width: 0; +} + +.app-layout.has-sidebar .main-wrapper { + padding-left: 240px; } -.navbar { +/* Header / Navbar */ +.header-nav { + position: sticky; + top: 0; + z-index: 20; display: flex; - justify-content: space-between; + height: 56px; align-items: center; - padding: 1rem 2rem; - background: var(--glass); - backdrop-filter: blur(10px); + justify-content: space-between; border-bottom: 1px solid var(--glass-border); + background: var(--bg-card); + backdrop-filter: blur(12px); + padding: 0 1.5rem; } -.nav-logo { - font-size: 1.5rem; - font-weight: 800; - background: linear-gradient(to right, var(--primary), var(--secondary)); - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; +.header-left { + display: flex; + align-items: center; + gap: 1rem; +} + +.sidebar-toggle-btn { + display: none; + padding: 0.4rem; + border-radius: 6px; + background: transparent; + border: 1px solid var(--glass-border); + color: var(--text-muted); + cursor: pointer; +} + +.sidebar-toggle-btn:hover { + background: var(--glass); + color: var(--text-main); } -.nav-links { +.breadcrumb-container { display: flex; - gap: 1.5rem; align-items: center; + gap: 0.5rem; + font-size: 0.85rem; } -.nav-links > .floating-reference { - display: inline-flex; +.breadcrumb-item { + color: var(--text-muted); + text-decoration: none; } -.nav-links a { - text-decoration: none; +.breadcrumb-separator { color: var(--text-muted); - font-weight: 500; - transition: color 0.2s, transform 0.2s; - display: inline-flex; - align-items: center; - gap: 0.45rem; + opacity: 0.5; } -.nav-links a:hover { +.breadcrumb-current { color: var(--text-main); - transform: translateY(-1px); + font-weight: 500; } -.user-name { - display: inline-flex; +.header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.header-search { + display: flex; align-items: center; gap: 0.5rem; - padding: 0.5rem 0.85rem; + background: var(--bg-dark); border: 1px solid var(--glass-border); - border-radius: 999px; + border-radius: 6px; + padding: 0.4rem 0.75rem; + width: 240px; +} + +.header-search input { + background: transparent; + border: none; color: var(--text-main); - background: rgba(255, 255, 255, 0.04); + font-size: 0.8rem; + outline: none; + width: 100%; +} + +.header-icon-btn { + position: relative; + background: var(--bg-dark); + border: 1px solid var(--glass-border); + color: var(--text-muted); + width: 34px; + height: 34px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.header-icon-btn:hover { + background: var(--glass); + color: var(--text-main); +} + +.notification-badge { + position: absolute; + top: -4px; + right: -4px; + background: var(--accent); + color: white; + font-size: 0.65rem; + font-weight: 700; + min-width: 16px; + height: 16px; + border-radius: 99px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--bg-card); +} + +/* Sidebar */ +.app-sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: 240px; + background: var(--surface-strong); + border-right: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + z-index: 50; + transition: transform 0.2s ease; +} + +.sidebar-top { + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1rem; + border-bottom: 1px solid var(--glass-border); +} + +.sidebar-branding { + display: flex; + align-items: center; + gap: 0.75rem; } -.user-name small { +.branding-icon { + width: 32px; + height: 32px; + border-radius: 6px; + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 0.85rem; +} + +.branding-text { + font-size: 0.9rem; + font-weight: 700; + color: var(--text-main); + letter-spacing: -0.01em; +} + +.sidebar-navigation { + flex: 1; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + overflow-y: auto; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + border-radius: 6px; color: var(--text-muted); - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.08em; + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.2s; } -.btn-logout { - border: 1px solid var(--glass-border); - background: transparent; +.sidebar-nav-item:hover { + background: var(--glass); color: var(--text-main); - width: 40px; - height: 40px; - border-radius: 999px; - display: inline-flex; +} + +.sidebar-nav-item.is-active { + background: var(--primary); + color: white; +} + +.sidebar-bottom { + padding: 0.75rem; + border-top: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border-radius: 6px; + background: var(--glass); +} + +.user-avatar-small { + width: 32px; + height: 32px; + border-radius: 6px; + background: var(--secondary); + display: flex; align-items: center; justify-content: center; + color: white; +} + +.user-meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.user-meta-name { + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-meta-role { + font-size: 0.7rem; + color: var(--text-muted); +} + +.sidebar-logout-btn { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + border-radius: 6px; + color: var(--accent); + background: transparent; + border: none; + width: 100%; + text-align: left; + font-size: 0.85rem; + font-weight: 500; cursor: pointer; } -.btn-logout:hover { - background: rgba(255, 255, 255, 0.08); +.sidebar-logout-btn:hover { + background: rgba(244, 63, 94, 0.1); } -.navbar { - position: sticky; - top: 0; +.sidebar-close-btn { + display: none; + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; +} + +.sidebar-mobile-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); z-index: 40; - box-shadow: 0 12px 30px rgba(2, 6, 23, 0.14); } +/* Workspace & Role Switchers in Header */ +.workspace-switcher, +.role-switcher { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--glass-border); + border-radius: 6px; + background: var(--bg-dark); +} + +.role-select, +.org-select, +.theme-select { + background: transparent; + border: none; + color: var(--text-main); + font-size: 0.75rem; + font-weight: 600; + padding: 0; + cursor: pointer; + outline: none; +} + +.role-select option, +.org-select option, +.theme-select option { + background: var(--surface-strong); + color: var(--text-main); +} + + +/* Responsive */ @media (max-width: 900px) { - .navbar { - padding: 1rem; - flex-direction: column; - gap: 1rem; - align-items: flex-start; + .app-layout.has-sidebar .main-wrapper { + padding-left: 0; } - .main-content { - padding: 1.25rem; + .app-sidebar { + transform: translateX(-100%); } - .nav-links { - width: 100%; - flex-wrap: wrap; - gap: 0.75rem; + .app-sidebar.is-open { + transform: translateX(0); } - .user-name { - order: 1; + .sidebar-toggle-btn { + display: flex; } -} -@media (max-width: 560px) { - .main-content { - padding: 1rem 0.85rem; + .sidebar-close-btn { + display: flex; } +} - .nav-logo { - font-size: 1.2rem; +@media (max-width: 640px) { + .header-search { + display: none; + } + + .breadcrumb-container { + display: none; } +} + + +.main-content { + flex: 1; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +.nav-logo { + font-size: 1.5rem; + font-weight: 800; + background: linear-gradient(to right, var(--primary), var(--secondary)); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.md-hide { + display: none; +} - .nav-links { - flex-direction: column; - align-items: stretch; +@media (max-width: 900px) { + .md-hide { + display: flex; } - .nav-links > .floating-reference { - width: 100%; + .main-content { + padding: 1.25rem; } +} + +.nav-organization-switcher { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.04); + padding: 0.4rem 0.8rem; + border-radius: 99px; + color: var(--text-muted); + border: 1px solid var(--glass-border); +} + +.nav-theme-switcher { + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.04); + padding: 0.4rem 0.8rem; + border-radius: 99px; + color: var(--text-muted); + border: 1px solid var(--glass-border); +} + +.theme-select, +.org-select { + background: transparent; + border: none; + color: var(--text-main); + font-size: 0.9rem; + font-weight: 500; + outline: none; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +.theme-select option, +.org-select option { + background: var(--surface-strong); + color: var(--text-main); +} - .nav-links > .floating-reference > a, - .nav-links > .floating-reference > button { - width: 100%; - justify-content: center; +@media (max-width: 560px) { + .main-content { + padding: 1rem 0.85rem; } - .user-name { - width: 100%; - justify-content: space-between; - flex-wrap: wrap; + .nav-logo { + font-size: 1.2rem; } - .btn-logout { - align-self: flex-start; + .auth-links { + display: none; } } + diff --git a/client/src/App.jsx b/client/src/App.jsx index 692a304..09f5423 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,29 +1,53 @@ import React from 'react'; -import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; -import Navbar from './components/Navbar'; +import { BrowserRouter as Router, Navigate, Route, Routes, useLocation } from 'react-router-dom'; +import { Toaster } from 'sonner'; +import AppLayout from './components/AppLayout'; import { SessionProvider } from './context/SessionContext'; +import { ThemeProvider, useTheme } from './context/ThemeContext'; import Community from './pages/Community'; import Dashboard from './pages/Dashboard'; import Login from './pages/Login'; +import ChangePassword from './pages/ChangePassword'; import Register from './pages/Register'; +import Settings from './pages/Settings'; +import { useSession } from './hooks/useSession'; import './App.css'; +const AppContent = () => { + const { theme } = useTheme(); + const { isAuthenticated, loading, requiresPasswordChange } = useSession(); + const location = useLocation(); + const toasterTheme = theme === 'light' ? 'light' : 'dark'; + + if (!loading && isAuthenticated && requiresPasswordChange && location.pathname !== '/change-password') { + return ; + } + + return ( + <> + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +}; + function App() { return ( - -
- -
- - } /> - } /> - } /> - } /> - -
-
-
+ + + + +
); } diff --git a/client/src/components/AnimatedSection.jsx b/client/src/components/AnimatedSection.jsx index 3499b57..79fd0c0 100644 --- a/client/src/components/AnimatedSection.jsx +++ b/client/src/components/AnimatedSection.jsx @@ -10,6 +10,11 @@ const AnimatedSection = ({ as: Tag = 'section', children, className = '', delay return undefined; } + if (typeof IntersectionObserver === 'undefined') { + setVisible(true); + return undefined; + } + const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { diff --git a/client/src/components/AppLayout.jsx b/client/src/components/AppLayout.jsx new file mode 100644 index 0000000..bb85481 --- /dev/null +++ b/client/src/components/AppLayout.jsx @@ -0,0 +1,24 @@ +import React, { useState } from 'react'; +import Sidebar from './Sidebar'; +import Navbar from './Navbar'; +import { useSession } from '../hooks/useSession'; + +const AppLayout = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = useState(false); + const { user } = useSession(); + + return ( +
+ setSidebarOpen(false)} /> + +
+ setSidebarOpen(!sidebarOpen)} /> +
+ {children} +
+
+
+ ); +}; + +export default AppLayout; diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 3498009..d9d9901 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -1,56 +1,59 @@ import React from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { Home, LogOut, MessageCircle, User as UserIcon } from 'lucide-react'; -import FloatingHint from './FloatingHint'; +import { useLocation, Link } from 'react-router-dom'; +import { ChevronRight, Menu } from 'lucide-react'; import { useSession } from '../hooks/useSession'; -import { formatRoleLabel } from '../utils/roles'; -const Navbar = () => { - const navigate = useNavigate(); - const { user, signOut } = useSession(); +const routeNames = { + '/': 'Dashboard', + '/community': 'Community', + '/settings': 'Settings', + '/change-password': 'Change Password', + '/login': 'Sign In', + '/register': 'Apply' +}; - const handleLogout = () => { - signOut(); - navigate('/login'); - }; +const Navbar = ({ onMenuToggle }) => { + const location = useLocation(); + const { user, organization } = useSession(); + + const currentPage = routeNames[location.pathname] || 'Dashboard'; return ( - + ); }; diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx new file mode 100644 index 0000000..476ab0a --- /dev/null +++ b/client/src/components/Sidebar.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { + LayoutDashboard, + MessageCircle, + LogOut, + X, + User, + Settings, + Shield +} from 'lucide-react'; +import { useSession } from '../hooks/useSession'; +import { formatRoleLabel } from '../utils/roles'; + +const Sidebar = ({ isOpen, onClose }) => { + const navigate = useNavigate(); + const session = useSession(); + const { user, signOut } = session; + + const handleLogout = () => { + signOut(); + navigate('/login'); + onClose(); + }; + + const allNavItems = [ + { label: 'Dashboard', path: '/', icon: LayoutDashboard }, + { label: 'Community', path: '/community', icon: MessageCircle }, + { label: 'Settings', path: '/settings', icon: Settings }, + ]; + + if (!user) return null; + + return ( + <> + {/* Mobile overlay */} + {isOpen && ( +
+ )} + + + + ); +}; + +export default Sidebar; diff --git a/client/src/context/SessionContext.jsx b/client/src/context/SessionContext.jsx index 8a572d5..25de80a 100644 --- a/client/src/context/SessionContext.jsx +++ b/client/src/context/SessionContext.jsx @@ -23,21 +23,6 @@ export const SessionProvider = ({ children }) => { setLoading(false); }; - const setUser = (nextUser) => { - const currentSession = readSession(); - if (!currentSession?.token) { - return; - } - - const nextSession = { - token: currentSession.token, - user: nextUser - }; - - writeSession(nextSession); - setSession(nextSession); - }; - const refreshSession = async () => { const currentSession = readSession(); if (!currentSession?.token) { @@ -48,15 +33,19 @@ export const SessionProvider = ({ children }) => { setLoading(true); try { - const currentUser = await authService.getCurrentUser(); + const authData = await authService.getCurrentUser(); const nextSession = { token: currentSession.token, - user: currentUser + user: authData.user, + member: authData.member || null, + organization: authData.organization || null, + organizations: authData.organizations || [], + requiresPasswordChange: authData.user?.requiresPasswordChange || authData.user?.requires_password_change || false }; writeSession(nextSession); setSession(nextSession); - return currentUser; + return authData; } catch (error) { signOut(); return null; @@ -88,12 +77,15 @@ export const SessionProvider = ({ children }) => { diff --git a/client/src/context/ThemeContext.jsx b/client/src/context/ThemeContext.jsx new file mode 100644 index 0000000..be61055 --- /dev/null +++ b/client/src/context/ThemeContext.jsx @@ -0,0 +1,115 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +const ThemeContext = createContext(null); + +export const THEMES = { + DARK: 'dark', + LIGHT: 'light', + MIDNIGHT: 'midnight', + FOREST: 'forest', + SUNSET: 'sunset' +}; + +const themeConfigs = { + [THEMES.DARK]: { + '--primary': '#6366f1', + '--secondary': '#a855f7', + '--accent': '#f43f5e', + '--bg-dark': '#0f172a', + '--bg-image': 'radial-gradient(at 0% 0%, hsla(253,16%,7%,1) 0, transparent 50%), radial-gradient(at 50% 0%, hsla(225,39%,30%,1) 0, transparent 50%), radial-gradient(at 100% 0%, hsla(339,49%,30%,1) 0, transparent 50%)', + '--bg-card': 'rgba(30, 41, 59, 0.7)', + '--text-main': '#f8fafc', + '--text-muted': '#94a3b8', + '--glass': 'rgba(255, 255, 255, 0.05)', + '--glass-border': 'rgba(255, 255, 255, 0.1)', + '--surface-strong': 'rgba(15, 23, 42, 0.88)', + '--color-scheme': 'dark' + }, + [THEMES.LIGHT]: { + '--primary': '#2563eb', + '--secondary': '#7c3aed', + '--accent': '#e11d48', + '--bg-dark': '#f8fafc', + '--bg-image': 'radial-gradient(at 0% 0%, hsla(210, 40%, 96%, 1) 0, transparent 50%), radial-gradient(at 50% 0%, hsla(220, 30%, 90%, 1) 0, transparent 50%), radial-gradient(at 100% 0%, hsla(230, 20%, 88%, 1) 0, transparent 50%)', + '--bg-card': 'rgba(255, 255, 255, 0.8)', + '--text-main': '#0f172a', + '--text-muted': '#475569', + '--glass': 'rgba(0, 0, 0, 0.03)', + '--glass-border': 'rgba(0, 0, 0, 0.08)', + '--surface-strong': 'rgba(255, 255, 255, 0.95)', + '--color-scheme': 'light' + }, + [THEMES.MIDNIGHT]: { + '--primary': '#818cf8', + '--secondary': '#c084fc', + '--accent': '#fb7185', + '--bg-dark': '#020617', + '--bg-image': 'radial-gradient(at 0% 0%, hsla(260, 50%, 10%, 1) 0, transparent 50%), radial-gradient(at 50% 0%, hsla(280, 40%, 15%, 1) 0, transparent 50%), radial-gradient(at 100% 0%, hsla(300, 30%, 10%, 1) 0, transparent 50%)', + '--bg-card': 'rgba(15, 23, 42, 0.75)', + '--text-main': '#f5f3ff', + '--text-muted': '#a5b4fc', + '--glass': 'rgba(139, 92, 246, 0.1)', + '--glass-border': 'rgba(139, 92, 246, 0.2)', + '--surface-strong': 'rgba(2, 6, 23, 0.92)', + '--color-scheme': 'dark' + }, + [THEMES.FOREST]: { + '--primary': '#10b981', + '--secondary': '#059669', + '--accent': '#f59e0b', + '--bg-dark': '#064e3b', + '--bg-image': 'radial-gradient(at 0% 0%, hsla(160, 50%, 5%, 1) 0, transparent 50%), radial-gradient(at 50% 0%, hsla(150, 40%, 15%, 1) 0, transparent 50%), radial-gradient(at 100% 0%, hsla(140, 30%, 10%, 1) 0, transparent 50%)', + '--bg-card': 'rgba(6, 78, 59, 0.7)', + '--text-main': '#ecfdf5', + '--text-muted': '#6ee7b7', + '--glass': 'rgba(16, 185, 129, 0.1)', + '--glass-border': 'rgba(16, 185, 129, 0.2)', + '--surface-strong': 'rgba(2, 44, 34, 0.9)', + '--color-scheme': 'dark' + }, + [THEMES.SUNSET]: { + '--primary': '#f97316', + '--secondary': '#db2777', + '--accent': '#fbbf24', + '--bg-dark': '#450a0a', + '--bg-image': 'radial-gradient(at 0% 0%, hsla(20, 50%, 10%, 1) 0, transparent 50%), radial-gradient(at 50% 0%, hsla(0, 40%, 15%, 1) 0, transparent 50%), radial-gradient(at 100% 0%, hsla(340, 30%, 10%, 1) 0, transparent 50%)', + '--bg-card': 'rgba(127, 29, 29, 0.7)', + '--text-main': '#fff7ed', + '--text-muted': '#fdba74', + '--glass': 'rgba(249, 115, 22, 0.1)', + '--glass-border': 'rgba(249, 115, 22, 0.2)', + '--surface-strong': 'rgba(69, 10, 10, 0.9)', + '--color-scheme': 'dark' + } +}; + +export const ThemeProvider = ({ children }) => { + const [theme, setTheme] = useState(() => { + return localStorage.getItem('apex-theme') || THEMES.DARK; + }); + + useEffect(() => { + const config = themeConfigs[theme]; + const root = document.documentElement; + + Object.entries(config).forEach(([property, value]) => { + root.style.setProperty(property, value); + }); + + localStorage.setItem('apex-theme', theme); + }, [theme]); + + return ( + + {children} + + ); +}; + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; diff --git a/client/src/index.css b/client/src/index.css index 64b0df9..9c6a4cf 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -22,10 +22,7 @@ body { background: var(--bg-dark); - background-image: - radial-gradient(at 0% 0%, hsla(253,16%,7%,1) 0, transparent 50%), - radial-gradient(at 50% 0%, hsla(225,39%,30%,1) 0, transparent 50%), - radial-gradient(at 100% 0%, hsla(339,49%,30%,1) 0, transparent 50%); + background-image: var(--bg-image); color: var(--text-main); min-height: 100vh; overflow-x: hidden; @@ -40,10 +37,10 @@ textarea { } select { - color-scheme: dark; + color-scheme: var(--color-scheme); background-image: - linear-gradient(45deg, transparent 50%, #7dd3fc 50%), - linear-gradient(135deg, #7dd3fc 50%, transparent 50%); + linear-gradient(45deg, transparent 50%, var(--primary) 50%), + linear-gradient(135deg, var(--primary) 50%, transparent 50%); background-position: calc(100% - 18px) calc(50% - 2px), calc(100% - 12px) calc(50% - 2px); @@ -54,8 +51,8 @@ select { select option, select optgroup { - background: #0f172a; - color: #e2e8f0; + background: var(--surface-strong); + color: var(--text-main); } .glass-card { diff --git a/client/src/lib/alerts.js b/client/src/lib/alerts.js index 62d1a51..7a263a8 100644 --- a/client/src/lib/alerts.js +++ b/client/src/lib/alerts.js @@ -1,4 +1,5 @@ import Swal from 'sweetalert2'; +import { toast } from 'sonner'; const baseConfig = { background: '#0f172a', @@ -15,39 +16,23 @@ const baseConfig = { } }; -export const showToast = ({ icon = 'success', title, text, timer = 2600 }) => Swal.fire({ - ...baseConfig, - icon, - title, - text, - toast: true, - position: 'top-end', - timer, - showConfirmButton: false, - timerProgressBar: true -}); +export const showToast = ({ icon = 'success', title, text, timer = 2600 }) => { + if (icon === 'success') { + toast.success(title || 'Success', { description: text, duration: timer }); + } else if (icon === 'error') { + toast.error(title || 'Error', { description: text, duration: timer }); + } else if (icon === 'info') { + toast.info(title || 'Info', { description: text, duration: timer }); + } else { + toast(title || 'Notification', { description: text, duration: timer }); + } +}; -export const showErrorAlert = (title, text) => Swal.fire({ - ...baseConfig, - icon: 'error', - title, - text -}); +export const showErrorAlert = (title, text) => toast.error(title || 'Error', { description: text, duration: 4000 }); -export const showSuccessAlert = (title, text) => Swal.fire({ - ...baseConfig, - icon: 'success', - title, - text -}); +export const showSuccessAlert = (title, text) => toast.success(title || 'Success', { description: text, duration: 4000 }); -export const showInfoAlert = (title, text, options = {}) => Swal.fire({ - ...baseConfig, - icon: 'info', - title, - text, - ...options -}); +export const showInfoAlert = (title, text, options = {}) => toast.info(title || 'Info', { description: text, duration: 4000 }); export const confirmAction = async ({ cancelButtonText = 'Cancel', diff --git a/client/src/lib/api.js b/client/src/lib/api.js index 18e9717..86e8caa 100644 --- a/client/src/lib/api.js +++ b/client/src/lib/api.js @@ -1,5 +1,6 @@ import axios from 'axios'; import { clearSession, readSession } from './session'; +import { normalizeApiData } from './normalize'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '/api'; @@ -31,6 +32,11 @@ api.interceptors.response.use( } ); +api.interceptors.response.use((response) => ({ + ...response, + data: normalizeApiData(response.data) +})); + export const getApiErrorMessage = (error, fallback = 'Something went wrong.') => ( error.response?.data?.message || error.response?.data?.error diff --git a/client/src/lib/normalize.js b/client/src/lib/normalize.js new file mode 100644 index 0000000..9841bd2 --- /dev/null +++ b/client/src/lib/normalize.js @@ -0,0 +1,26 @@ +const isPlainObject = (value) => Object.prototype.toString.call(value) === '[object Object]'; + +const snakeToCamel = (str) => str.replace(/(_[a-z])/g, (match) => match.toUpperCase().replace('_', '')); + +export const normalizeApiData = (value) => { + if (Array.isArray(value)) { + return value.map(normalizeApiData); + } + + if (!isPlainObject(value)) { + return value; + } + + const normalized = {}; + + for (const [key, entry] of Object.entries(value)) { + const camelKey = snakeToCamel(key); + normalized[camelKey] = normalizeApiData(entry); + } + + if (Object.prototype.hasOwnProperty.call(normalized, 'id') && !Object.prototype.hasOwnProperty.call(normalized, '_id')) { + normalized._id = normalized.id; + } + + return normalized; +}; diff --git a/client/src/pages/ChangePassword.jsx b/client/src/pages/ChangePassword.jsx new file mode 100644 index 0000000..0878dd5 --- /dev/null +++ b/client/src/pages/ChangePassword.jsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { KeyRound, Lock, RotateCw } from 'lucide-react'; +import { useSession } from '../hooks/useSession'; +import { authService } from '../services/authService'; +import { showErrorAlert, showToast } from '../lib/alerts'; +import { getApiErrorMessage } from '../lib/api'; +import '../styles/Auth.css'; + +const ChangePassword = () => { + const navigate = useNavigate(); + const { isAuthenticated, loading, requiresPasswordChange, refreshSession } = useSession(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + if (!loading && !isAuthenticated) { + return ; + } + + const handleSubmit = async (event) => { + event.preventDefault(); + setSubmitting(true); + setError(''); + + try { + await authService.changePassword({ currentPassword, newPassword }); + await showToast({ + icon: 'success', + title: 'Password Updated', + text: 'Your account is now ready to use.' + }); + await refreshSession(); + navigate('/'); + } catch (requestError) { + const message = getApiErrorMessage(requestError, 'Failed to change password.'); + setError(message); + await showErrorAlert('Password Change Failed', message); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ Account Security +

{requiresPasswordChange ? 'Set your password before continuing.' : 'Update your password anytime.'}

+

The backend requires this step when a temporary password is issued.

+
+ Auth protected + Temporary access +
+
+ +
+
+

Change Password

+

Use your current password, then choose a new one.

+
+ + {error &&
{error}
} + +
+
+ + setCurrentPassword(event.target.value)} + required + /> +
+
+ + setNewPassword(event.target.value)} + required + minLength={8} + /> +
+ +
+
+
+
+ ); +}; + +export default ChangePassword; diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx index ccbbe72..0ef095e 100644 --- a/client/src/pages/Dashboard.jsx +++ b/client/src/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Activity, @@ -12,19 +12,20 @@ import { Home, LayoutDashboard, MapPin, + Menu, MessageCircle, Plus, ScrollText, Sparkles, Upload, Users, - Wrench + Wrench, + X } from 'lucide-react'; import AnimatedSection from '../components/AnimatedSection'; import FloatingHint from '../components/FloatingHint'; import { ManagementCharts, - StaffCharts, SuperAdminCharts, TenantCharts } from '../components/charts/DashboardCharts'; @@ -49,7 +50,7 @@ import { suggestionService } from '../services/suggestionService'; import { unitService } from '../services/unitService'; import { formatCurrency, formatDate } from '../utils/format'; import { downloadBlob, openBlobInNewTab } from '../utils/files'; -import { formatRoleLabel, isManagementRole, isStaffRole, ROLES } from '../utils/roles'; +import { formatRoleLabel, isManagementRole, ROLES } from '../utils/roles'; import '../styles/Dashboard.css'; const emptyRepairDraft = { @@ -147,6 +148,7 @@ const RepairSection = ({ newRepair, onSubmit, onUpdate, + onDelete, properties, requests, setNewRepair, @@ -255,34 +257,49 @@ const RepairSection = ({
)} - {canManage && request.status !== 'resolved' && ( + {(canManage || onDelete) && (
- - - - - - + {canManage && request.status !== 'resolved' && ( + <> + + + + + + + + )} + {onDelete && ( + + + + )}
)} @@ -303,52 +320,101 @@ const WorkspaceShell = ({ subtitle, title, children -}) => ( -
- + {footer &&
{footer}
} + -
- {children} +
+ {children} +
-
-); + ); +}; const ActivityLogSection = ({ logs }) => ( @@ -435,7 +501,6 @@ const Dashboard = () => { const userRole = user?.role; const isTenant = userRole === ROLES.TENANT; const isManagement = isManagementRole(userRole); - const isStaff = isStaffRole(userRole); const isSuperAdmin = userRole === ROLES.SUPER_ADMIN; const tenantLease = leases[0] || null; const tenantProperties = tenantLease?.property ? [tenantLease.property] : user?.interestedProperty ? [user.interestedProperty] : []; @@ -563,22 +628,6 @@ const Dashboard = () => { setAdminSummary(nextAdminSummary); setOrganizations(nextOrganizations); setAuditLogs(nextAuditLogs); - } else if (isStaff) { - const [nextNotifications, nextRepairs] = await Promise.all([ - notificationService.getNotifications(), - repairService.getRepairs() - ]); - - setNotifications(nextNotifications); - setRepairRequests(nextRepairs); - setProperties([]); - setPayments([]); - setLeases([]); - setPendingRegistrations([]); - setSuggestions([]); - setAdminSummary(null); - setOrganizations([]); - setAuditLogs([]); } } catch (requestError) { setError(getApiErrorMessage(requestError, 'Failed to load dashboard data.')); @@ -797,6 +846,30 @@ const Dashboard = () => { } }; + const handleDeleteLease = async (leaseId) => { + const confirmed = await confirmAction({ + title: 'Delete Lease?', + text: 'Are you sure you want to remove this lease agreement? The tenant will no longer have access to it.', + confirmButtonText: 'Delete', + cancelButtonText: 'Cancel', + icon: 'warning' + }); + + if (!confirmed) return; + + try { + await leaseService.deleteLease(leaseId); + await showToast({ + icon: 'success', + title: 'Lease Deleted', + text: 'The lease agreement has been removed.' + }); + loadDashboard(); + } catch (requestError) { + await showErrorAlert('Delete Failed', getApiErrorMessage(requestError, 'Failed to delete the lease.')); + } + }; + const handlePropertySubmit = async (event) => { event.preventDefault(); @@ -831,10 +904,34 @@ const Dashboard = () => { } }; + const handleDeleteProperty = async (propertyId, propertyName) => { + const confirmed = await confirmAction({ + title: 'Delete Property?', + text: `Are you sure you want to delete "${propertyName}"? This will remove all associated units and data.`, + confirmButtonText: 'Delete', + cancelButtonText: 'Cancel', + icon: 'warning' + }); + + if (!confirmed) return; + + try { + await propertyService.deleteProperty(propertyId); + await showToast({ + icon: 'success', + title: 'Property Deleted', + text: `${propertyName} has been removed from your portfolio.` + }); + loadDashboard(); + } catch (requestError) { + await showErrorAlert('Delete Failed', getApiErrorMessage(requestError, 'Failed to delete property.')); + } + }; + const handleGenerateReminder = async (tenantId) => { try { const response = await reminderService.generateReminder({ tenantId }); - await showInfoAlert('AI Reminder Draft', response.reminderText, { + await showInfoAlert('AI Reminder Draft', response.reminderText || response.message || '', { width: 720 }); } catch (requestError) { @@ -888,6 +985,30 @@ const Dashboard = () => { } }; + const handleDeleteRepair = async (repairId) => { + const confirmed = await confirmAction({ + title: 'Delete Request?', + text: 'Are you sure you want to remove this maintenance request? This action cannot be undone.', + confirmButtonText: 'Delete', + cancelButtonText: 'Cancel', + icon: 'warning' + }); + + if (!confirmed) return; + + try { + await repairService.deleteRepair(repairId); + await showToast({ + icon: 'success', + title: 'Request Deleted', + text: 'The maintenance ticket has been removed.' + }); + loadDashboard(); + } catch (requestError) { + await showErrorAlert('Delete Failed', getApiErrorMessage(requestError, 'Failed to delete the maintenance request.')); + } + }; + useEffect(() => { if (activeWorkspaceSection !== 'portfolio') { setSelectedProperty(null); @@ -1103,9 +1224,7 @@ const Dashboard = () => {

{isManagement ? 'Manage properties, leases, applications, and collections from one place.' - : isStaff - ? 'Track assigned maintenance work and stay aligned with management.' - : 'Everything you need for your rental stay is here.'} + : 'Everything you need for your rental stay is here.'}

{error &&
{error}
} @@ -1416,6 +1535,15 @@ const Dashboard = () => { Download + + + )) :

No leases uploaded yet.

} @@ -1592,6 +1720,15 @@ const Dashboard = () => { Edit + + + )) : ( @@ -1744,6 +1881,7 @@ const Dashboard = () => { newRepair={newRepair} onSubmit={handleRepairSubmit} onUpdate={handleUpdateRepair} + onDelete={handleDeleteRepair} properties={properties} requests={repairRequests} setNewRepair={setNewRepair} @@ -1902,6 +2040,7 @@ const Dashboard = () => { newRepair={newRepair} onSubmit={handleRepairSubmit} onUpdate={handleUpdateRepair} + onDelete={handleDeleteRepair} properties={tenantProperties} requests={repairRequests} setNewRepair={setNewRepair} @@ -1912,29 +2051,6 @@ const Dashboard = () => { )} - {isStaff && ( - <> - - } label="Assigned Tickets" value={repairRequests.length} subtext="Maintenance queue" hint="All current maintenance tickets assigned to your role." /> - } label="Notifications" value={notifications.length} subtext="Team updates" color="var(--secondary)" hint="Unread notices and workflow updates tied to your account." /> - - - - - )} ); }; diff --git a/client/src/pages/Landing.jsx b/client/src/pages/Landing.jsx index 2106930..233217b 100644 --- a/client/src/pages/Landing.jsx +++ b/client/src/pages/Landing.jsx @@ -60,8 +60,8 @@ const features = [ ]; const workflow = [ - 'Landlord or manager signs up and adds properties.', - 'Tenants apply for open units and get reviewed cleanly.', + 'Admin approves landlord accounts before portfolio access is granted.', + 'Landlords add managers and review tenant applications for open units.', 'Rent, maintenance, lease documents, and notices stay in one responsive workspace.' ]; @@ -95,9 +95,9 @@ const Landing = () => { {rotatingTaglines[activeTagline]}
- + - Create Account + Apply for a Unit @@ -105,7 +105,7 @@ const Landing = () => {
- Built for landlords, tenants, caretakers, and SaaS operators. + Built for landlords, tenants, managers, and SaaS operators. Responsive on desktop, tablet, and mobile.
diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx index 693996a..bd88cea 100644 --- a/client/src/pages/Login.jsx +++ b/client/src/pages/Login.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router-dom'; -import { Building, Home, Lock, LogIn, Mail, ShieldCheck } from 'lucide-react'; +import { Lock, LogIn, Mail } from 'lucide-react'; import { useSession } from '../hooks/useSession'; import { showErrorAlert, showToast } from '../lib/alerts'; import { getApiErrorMessage } from '../lib/api'; @@ -26,16 +26,25 @@ const Login = () => { try { const response = await authService.login({ email, password }); + const requiresPasswordChange = Boolean(response.user?.requiresPasswordChange || response.user?.requires_password_change); signIn({ - token: response.token, - user: response.user + token: response.tokens?.accessToken || response.token, + user: response.user, + member: response.member, + organization: response.organization, + organizations: response.organizations, + requiresPasswordChange }); - await showToast({ - icon: 'success', - title: 'Welcome Back', - text: `Signed in as ${response.user.name}.` - }); - navigate('/'); + if (requiresPasswordChange) { + navigate('/change-password'); + } else { + await showToast({ + icon: 'success', + title: 'Welcome Back', + text: `Signed in as ${response.user.name}.` + }); + navigate('/'); + } } catch (requestError) { const message = getApiErrorMessage(requestError, 'Invalid email or password.'); setError(message); @@ -46,57 +55,45 @@ const Login = () => { }; return ( -
-
-
- Apex Access -

One sign-in. All roles.

-

Clean layout. Fast return.

-
- Tenant - Landlord - Manager -
+
+
+ Apex Access +
+

Sign in

+

Use the email and password assigned to your account. Landlords are approved by admin, and managers are added by landlords.

-
-
-

Welcome Back

-

Access your workspace.

-
- - {error &&
{error}
} + {error &&
{error}
} -
-
- - setEmail(event.target.value)} - required - /> -
-
- - setPassword(event.target.value)} - required - /> -
- -
- -
- Need an account? Create one +
+
+ + setEmail(event.target.value)} + required + /> +
+
+ + setPassword(event.target.value)} + required + />
+ +
+ +
+ Need an account? Create one
diff --git a/client/src/pages/Register.jsx b/client/src/pages/Register.jsx index 00f3826..3f202fc 100644 --- a/client/src/pages/Register.jsx +++ b/client/src/pages/Register.jsx @@ -1,19 +1,12 @@ import React, { useEffect, useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router-dom'; -import { Building, Home, Lock, Mail, Phone, ShieldCheck, User, UserPlus } from 'lucide-react'; +import { Building, Home, Lock, Mail, Phone, User, UserPlus } from 'lucide-react'; import { useSession } from '../hooks/useSession'; import { showErrorAlert, showToast } from '../lib/alerts'; import { getApiErrorMessage } from '../lib/api'; import { authService } from '../services/authService'; -import { formatRoleLabel, ROLES, SELF_SERVICE_REGISTRATION_OPTIONS } from '../utils/roles'; import '../styles/Auth.css'; -const roleIcons = { - [ROLES.TENANT]: Home, - [ROLES.LANDLORD]: Building, - [ROLES.PROPERTY_MANAGER]: ShieldCheck -}; - const Register = () => { const navigate = useNavigate(); const { isAuthenticated, loading: sessionLoading } = useSession(); @@ -22,7 +15,6 @@ const Register = () => { email: '', password: '', phoneNumber: '', - role: ROLES.TENANT, interestedProperty: '', interestedUnit: '' }); @@ -56,16 +48,6 @@ const Register = () => { const selectedUnits = selectedProperty?.units || []; const handleFieldChange = (field, value) => { - if (field === 'role' && value !== ROLES.TENANT) { - setFormData((current) => ({ - ...current, - role: value, - interestedProperty: '', - interestedUnit: '' - })); - return; - } - setFormData((current) => ({ ...current, [field]: value @@ -78,11 +60,14 @@ const Register = () => { setError(''); try { - const response = await authService.register(formData); + const response = await authService.register({ + ...formData, + role: 'tenant' + }); await showToast({ icon: 'success', - title: formData.role === ROLES.TENANT ? 'Application Submitted' : 'Account Created', - text: response.message || 'Your account request has been received.' + title: 'Application Submitted', + text: response.message || 'Your application has been received. A confirmation email will follow after review.' }); navigate('/login'); } catch (requestError) { @@ -95,163 +80,110 @@ const Register = () => { }; return ( -
-
-
- Account Setup -

Pick role. Pick place. Apply fast.

-

Clear color shows what is selected.

-
-
- Role - {formatRoleLabel(formData.role)} -
- {formData.role === ROLES.TENANT && ( - <> -
- Property - {selectedProperty?.name || 'Not selected'} -
-
- Unit - {formData.interestedUnit ? `Unit ${formData.interestedUnit}` : 'Not selected'} -
- - )} -
+
+
+
+

Apply

-
-
-

Create Account

-

Create your access.

+ {error &&
{error}
} + +
+
+ + handleFieldChange('name', event.target.value)} + required + /> +
+
+ + handleFieldChange('email', event.target.value)} + required + /> +
+
+ + handleFieldChange('phoneNumber', event.target.value)} + /> +
+
+ + handleFieldChange('password', event.target.value)} + required + />
- {error &&
{error}
} - - -
- - handleFieldChange('name', event.target.value)} - required - /> -
- - handleFieldChange('email', event.target.value)} + + {loadingProperties ? ( +

Loading properties...

+ ) : ( + + )}
-
- - handleFieldChange('phoneNumber', event.target.value)} - /> -
-
- - handleFieldChange('password', event.target.value)} - required - /> -
-
- -
- {SELF_SERVICE_REGISTRATION_OPTIONS.map((option) => { - const Icon = roleIcons[option.value] || ShieldCheck; - const selected = formData.role === option.value; - return ( - - ); - })} + {formData.interestedProperty && ( +
+ +
+ {selectedUnits.length > 0 ? selectedUnits.map((unit) => ( + + )) : ( +

No open units.

+ )}
- - {formData.role === ROLES.TENANT && ( - <> -
- - {loadingProperties ? ( -

Loading properties...

- ) : ( - - )} -
- - {formData.interestedProperty && ( -
- -
- {selectedUnits.length > 0 ? selectedUnits.map((unit) => ( - - )) : ( -

No open units.

- )} -
-
- )} - - )} - - - - -
- Already have an account? Sign in -
+ )} + + + + +
+ Sign in
diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx new file mode 100644 index 0000000..ddcef27 --- /dev/null +++ b/client/src/pages/Settings.jsx @@ -0,0 +1,637 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { + Building, + CreditCard, + Mail, + MessageCircle, + RefreshCw, + ShieldCheck, + UserPlus, + Wrench, + Wallet, + Users, + Settings2, + CheckCircle2, + Building2, + BellRing +} from 'lucide-react'; +import { useSession } from '../hooks/useSession'; +import { authService } from '../services/authService'; +import { adminService } from '../services/adminService'; +import { billingService } from '../services/billingService'; +import { messageService } from '../services/messageService'; +import { paymentService } from '../services/paymentService'; +import { reminderService } from '../services/reminderService'; +import { showErrorAlert, showInfoAlert, showToast } from '../lib/alerts'; +import { getApiErrorMessage } from '../lib/api'; +import { confirmAction } from '../lib/alerts'; +import { ROLES, isManagementRole } from '../utils/roles'; +import '../styles/Dashboard.css'; +import '../styles/Auth.css'; + +const defaultPaymentForm = { + mpesaShortcode: '', + mpesaConsumerKey: '', + mpesaConsumerSecret: '', + mpesaPasskey: '', + bankName: '', + accountNumber: '', + accountName: '', + paymentMethods: { + mpesa: true, + bank_transfer: false + } +}; + +const defaultOrgBillingDraft = { + pricePerUnit: '', + billingCycleMonths: '', + status: 'trial' +}; + +const normalizeMaskedValue = (value) => (value && value !== '********' ? value : ''); + +const Section = ({ icon: Icon, title, subtitle, children, action }) => ( +
+
+
+

+ {Icon && } + {title} +

+ {subtitle &&

{subtitle}

} +
+ {action} +
+ {children} +
+); + +const Settings = () => { + const navigate = useNavigate(); + const { isAuthenticated, loading, user, organization, requiresPasswordChange } = useSession(); + const isOrgManager = user?.role !== ROLES.SUPER_ADMIN && isManagementRole(user?.role); + const isSuperAdmin = user?.role === ROLES.SUPER_ADMIN; + + const [loadingState, setLoadingState] = useState(true); + const [billing, setBilling] = useState(null); + const [billingBusy, setBillingBusy] = useState(false); + const [staff, setStaff] = useState([]); + const [staffBusy, setStaffBusy] = useState(false); + const [staffDraft, setStaffDraft] = useState({ + name: '', + email: '', + password: '', + phoneNumber: '', + role: ROLES.PROPERTY_MANAGER + }); + const [paymentForm, setPaymentForm] = useState(defaultPaymentForm); + const [paymentBusy, setPaymentBusy] = useState(false); + const [reminderForm, setReminderForm] = useState({ + enabled: false, + beforeDays: 3, + afterDays: 3 + }); + const [reminderBusy, setReminderBusy] = useState(false); + const [pendingUsers, setPendingUsers] = useState([]); + const [organizations, setOrganizations] = useState([]); + const [orgBillingDrafts, setOrgBillingDrafts] = useState({}); + const [adminBusy, setAdminBusy] = useState(false); + + const [messageBusy, setMessageBusy] = useState(false); + + const organizationId = organization?._id || organization?.id || null; + + useEffect(() => { + if (!isAuthenticated) { + return; + } + + const load = async () => { + setLoadingState(true); + try { + const tasks = []; + + if (isOrgManager) { + tasks.push( + billingService.getMyBilling().then(setBilling), + authService.getStaff().then(setStaff), + paymentService.getPaymentSettings().then((data) => { + setPaymentForm({ + mpesaShortcode: normalizeMaskedValue(data.mpesaShortcode), + mpesaConsumerKey: normalizeMaskedValue(data.mpesaConsumerKey), + mpesaConsumerSecret: normalizeMaskedValue(data.mpesaConsumerSecret), + mpesaPasskey: normalizeMaskedValue(data.mpesaPasskey), + bankName: data.bankDetails?.bankName || '', + accountNumber: data.bankDetails?.accountNumber || '', + accountName: data.bankDetails?.accountName || '', + paymentMethods: { + mpesa: (data.paymentMethods || []).includes('mpesa'), + bank_transfer: (data.paymentMethods || []).includes('bank_transfer') + } + }); + }).catch(() => null) + ); + } + + if (isSuperAdmin) { + tasks.push( + adminService.getPendingUsers().then(setPendingUsers), + adminService.getOrganizations().then((items) => { + setOrganizations(items); + const draftMap = {}; + for (const item of items) { + draftMap[item._id || item.id] = { + pricePerUnit: item.pricePerUnit ?? item.price_per_unit ?? '', + billingCycleMonths: item.billingCycleMonths ?? item.billing_cycle_months ?? '', + status: item.status || 'trial' + }; + } + setOrgBillingDrafts(draftMap); + }) + ); + } + + await Promise.all(tasks); + } catch (requestError) { + await showErrorAlert('Settings Load Failed', getApiErrorMessage(requestError, 'Failed to load settings.')); + } finally { + setLoadingState(false); + } + }; + + load(); + }, [isAuthenticated, isOrgManager, isSuperAdmin]); + + const selectedPaymentMethods = useMemo(() => ( + Object.entries(paymentForm.paymentMethods) + .filter(([, enabled]) => enabled) + .map(([method]) => method) + ), [paymentForm.paymentMethods]); + + if (!loading && !isAuthenticated) { + return ; + } + + const handlePaymentMethodChange = (method, enabled) => { + setPaymentForm((current) => ({ + ...current, + paymentMethods: { + ...current.paymentMethods, + [method]: enabled + } + })); + }; + + const savePaymentSettings = async (event) => { + event.preventDefault(); + setPaymentBusy(true); + try { + await paymentService.updatePaymentSettings({ + ...(paymentForm.mpesaShortcode ? { mpesaShortcode: paymentForm.mpesaShortcode } : {}), + ...(paymentForm.mpesaConsumerKey ? { mpesaConsumerKey: paymentForm.mpesaConsumerKey } : {}), + ...(paymentForm.mpesaConsumerSecret ? { mpesaConsumerSecret: paymentForm.mpesaConsumerSecret } : {}), + ...(paymentForm.mpesaPasskey ? { mpesaPasskey: paymentForm.mpesaPasskey } : {}), + bankDetails: { + bankName: paymentForm.bankName, + accountNumber: paymentForm.accountNumber, + accountName: paymentForm.accountName + }, + paymentMethods: selectedPaymentMethods + }); + + await showToast({ + icon: 'success', + title: 'Payment Settings Saved', + text: 'The payment configuration has been updated.' + }); + } catch (requestError) { + await showErrorAlert('Payment Settings Failed', getApiErrorMessage(requestError, 'Failed to update payment settings.')); + } finally { + setPaymentBusy(false); + } + }; + + const updateStaffDraft = (field, value) => { + setStaffDraft((current) => ({ ...current, [field]: value })); + }; + + const addStaff = async (event) => { + event.preventDefault(); + setStaffBusy(true); + try { + await authService.addStaff({ + name: staffDraft.name, + email: staffDraft.email, + password: staffDraft.password, + phoneNumber: staffDraft.phoneNumber, + role: ROLES.PROPERTY_MANAGER + }); + await showToast({ + icon: 'success', + title: 'Staff Added', + text: `${staffDraft.name} can now sign in.` + }); + setStaffDraft({ name: '', email: '', password: '', phoneNumber: '', role: ROLES.PROPERTY_MANAGER }); + const nextStaff = await authService.getStaff(); + setStaff(nextStaff); + } catch (requestError) { + await showErrorAlert('Staff Creation Failed', getApiErrorMessage(requestError, 'Failed to add staff.')); + } finally { + setStaffBusy(false); + } + }; + + const handlePaySubscription = async () => { + const confirmed = await confirmAction({ + title: 'Open subscription payment?', + text: 'This will open the Paystack payment link returned by the backend.', + confirmButtonText: 'Open Link', + cancelButtonText: 'Cancel', + icon: 'question' + }); + + if (!confirmed) return; + + setBillingBusy(true); + try { + const response = await billingService.paySubscription(); + if (response.paymentUrl) { + window.open(response.paymentUrl, '_blank', 'noopener,noreferrer'); + } else { + await showInfoAlert('Billing Status', response.message || 'No balance due at this time.'); + } + } catch (requestError) { + await showErrorAlert('Billing Failed', getApiErrorMessage(requestError, 'Failed to initialize subscription payment.')); + } finally { + setBillingBusy(false); + } + }; + + const toggleReminders = async (enabled) => { + setReminderBusy(true); + try { + const response = await reminderService.toggleAutoReminders(enabled); + await showToast({ + icon: 'success', + title: response.message || 'Reminder setting updated', + text: '' + }); + setReminderForm((current) => ({ ...current, enabled })); + } catch (requestError) { + await showErrorAlert('Reminder Toggle Failed', getApiErrorMessage(requestError, 'Failed to update reminder state.')); + } finally { + setReminderBusy(false); + } + }; + + const saveReminderSettings = async (event) => { + event.preventDefault(); + setReminderBusy(true); + try { + const response = await reminderService.updateReminderSettings({ + beforeDays: reminderForm.beforeDays, + afterDays: reminderForm.afterDays + }); + await showToast({ + icon: 'success', + title: response.message || 'Reminder settings updated', + text: '' + }); + } catch (requestError) { + await showErrorAlert('Reminder Settings Failed', getApiErrorMessage(requestError, 'Failed to save reminder settings.')); + } finally { + setReminderBusy(false); + } + }; + + const runAllReminders = async () => { + setReminderBusy(true); + try { + const response = await reminderService.triggerAllReminders(); + await showInfoAlert('Reminders Triggered', response.message || 'Reminder batch executed.'); + } catch (requestError) { + await showErrorAlert('Reminder Batch Failed', getApiErrorMessage(requestError, 'Failed to trigger reminders.')); + } finally { + setReminderBusy(false); + } + }; + + const toggleGlobalChat = async (enabled) => { + setMessageBusy(true); + try { + const response = await messageService.toggleGlobalChat(enabled); + await showToast({ + icon: 'success', + title: response.message || 'Global chat updated', + text: '' + }); + } catch (requestError) { + await showErrorAlert('Chat Toggle Failed', getApiErrorMessage(requestError, 'Failed to update chat availability.')); + } finally { + setMessageBusy(false); + } + }; + + const updateOrgBillingDraft = (orgId, field, value) => { + setOrgBillingDrafts((current) => ({ + ...current, + [orgId]: { + ...current[orgId], + [field]: value + } + })); + }; + + const saveOrgBilling = async (orgId) => { + const draft = orgBillingDrafts[orgId]; + if (!draft) return; + + setAdminBusy(true); + try { + await adminService.updateOrganizationBilling(orgId, { + pricePerUnit: Number(draft.pricePerUnit || 0), + billingCycleMonths: Number(draft.billingCycleMonths || 1), + status: draft.status + }); + await showToast({ + icon: 'success', + title: 'Organization Updated', + text: 'Billing settings saved.' + }); + } catch (requestError) { + await showErrorAlert('Organization Billing Failed', getApiErrorMessage(requestError, 'Failed to update organization billing.')); + } finally { + setAdminBusy(false); + } + }; + + const approvePendingUser = async (userId, name) => { + setAdminBusy(true); + try { + await adminService.approveUser(userId); + setPendingUsers((current) => current.filter((item) => item._id !== userId)); + await showToast({ + icon: 'success', + title: 'User Approved', + text: `${name} is now active.` + }); + } catch (requestError) { + await showErrorAlert('Approval Failed', getApiErrorMessage(requestError, 'Failed to approve user.')); + } finally { + setAdminBusy(false); + } + }; + + return ( +
+
+
+

Settings

+

Backend-aligned workspace controls, billing, and platform administration.

+
+
+ +
+
+
+

User: {user?.name}

+

Role: {user?.role}

+

Organization: {organization?.name || 'No active organization'}

+

Password change required: {requiresPasswordChange ? 'Yes' : 'No'}

+ +
+
+
+ + {isOrgManager && ( + <> +
{billingBusy ? 'Opening...' : 'Pay Subscription'}} + > + {billing ? ( +
+
+

Organization: {billing.organization?.name}

+

Status: {billing.organization?.status}

+

Occupied units: {billing.currentUsage?.occupiedUnits ?? 0}

+

Estimated next bill: {billing.currentUsage?.estimatedNextBill ?? 0}

+
+
+ ) :

Billing data not loaded.

} +
+ +
+
+
+
+ + updateStaffDraft('name', event.target.value)} required /> +
+
+ + updateStaffDraft('email', event.target.value)} required /> +
+
+ + updateStaffDraft('phoneNumber', event.target.value)} /> +
+
+ + updateStaffDraft('password', event.target.value)} required /> +
+
+
+ +
+
+ +
+ {staff.length > 0 ? staff.map((item) => ( +
+
+ {item.name} + {item.role} +
+

{item.email}

+

{item.status}

+
+ )) :

No staff records loaded.

} +
+
+ +
+
+
+
+ + setPaymentForm((current) => ({ ...current, mpesaShortcode: event.target.value }))} /> +
+
+ + setPaymentForm((current) => ({ ...current, mpesaConsumerKey: event.target.value }))} /> +
+
+ + setPaymentForm((current) => ({ ...current, mpesaConsumerSecret: event.target.value }))} /> +
+
+ + setPaymentForm((current) => ({ ...current, mpesaPasskey: event.target.value }))} /> +
+
+ + setPaymentForm((current) => ({ ...current, bankName: event.target.value }))} /> +
+
+ + setPaymentForm((current) => ({ ...current, accountNumber: event.target.value }))} /> +
+
+ + setPaymentForm((current) => ({ ...current, accountName: event.target.value }))} /> +
+
+
+ +
+ + +
+
+
+ +
+
+
+ +
+
+ + + +
+
+
+
+ + setReminderForm((current) => ({ ...current, beforeDays: Number(event.target.value) }))} + /> +
+
+ + setReminderForm((current) => ({ ...current, afterDays: Number(event.target.value) }))} + /> +
+
+
+ +
+
+
+ +
+
+ + +
+
+ + )} + + {isSuperAdmin && ( + <> +
+
+ {pendingUsers.length > 0 ? pendingUsers.map((item) => ( +
+
+ {item.name} + {item.role} +
+

{item.email}

+

{item.interestedProperty?.name || 'No property attached'}

+ +
+ )) :

No pending users.

} +
+
+ +
+
+ {organizations.length > 0 ? organizations.map((item) => { + const id = item._id || item.id; + const draft = orgBillingDrafts[id] || defaultOrgBillingDraft; + return ( +
+
+ {item.name} + {item.status} +
+
+ + updateOrgBillingDraft(id, 'pricePerUnit', event.target.value)} + /> +
+
+ + updateOrgBillingDraft(id, 'billingCycleMonths', event.target.value)} + /> +
+
+ + +
+ +
+ ); + }) :

No organizations found.

} +
+
+ + )} + + {loadingState &&

Loading settings...

} +
+ ); +}; + +export default Settings; diff --git a/client/src/services/adminService.js b/client/src/services/adminService.js index c87425b..77e2ba2 100644 --- a/client/src/services/adminService.js +++ b/client/src/services/adminService.js @@ -16,5 +16,20 @@ export const adminService = { params: { limit } }); return response.data; + }, + + async getPendingUsers() { + const response = await api.get('/admin/users/pending'); + return response.data; + }, + + async approveUser(userId) { + const response = await api.post(`/admin/users/${userId}/approve`); + return response.data; + }, + + async updateOrganizationBilling(organizationId, payload) { + const response = await api.put(`/admin/organizations/${organizationId}/billing`, payload); + return response.data; } }; diff --git a/client/src/services/authService.js b/client/src/services/authService.js index 2f3a583..b5ecb10 100644 --- a/client/src/services/authService.js +++ b/client/src/services/authService.js @@ -18,6 +18,21 @@ export const authService = { async getCurrentUser() { const response = await api.get('/auth/me'); - return response.data.user; + return response.data; + }, + + async changePassword(payload) { + const response = await api.post('/auth/change-password', payload); + return response.data; + }, + + async getStaff() { + const response = await api.get('/auth/staff'); + return response.data; + }, + + async addStaff(payload) { + const response = await api.post('/auth/staff', payload); + return response.data; } }; diff --git a/client/src/services/billingService.js b/client/src/services/billingService.js new file mode 100644 index 0000000..f9eb27f --- /dev/null +++ b/client/src/services/billingService.js @@ -0,0 +1,13 @@ +import api from '../lib/api'; + +export const billingService = { + async getMyBilling() { + const response = await api.get('/billing/my-billing'); + return response.data; + }, + + async paySubscription() { + const response = await api.post('/billing/pay-subscription'); + return response.data; + } +}; diff --git a/client/src/services/leaseService.js b/client/src/services/leaseService.js index 03ae8ec..70f12da 100644 --- a/client/src/services/leaseService.js +++ b/client/src/services/leaseService.js @@ -25,5 +25,10 @@ export const leaseService = { responseType: 'blob' }); return response.data; + }, + + async deleteLease(leaseId) { + const response = await api.delete(`/leases/${leaseId}`); + return response.data; } }; diff --git a/client/src/services/messageService.js b/client/src/services/messageService.js index 091b527..6579a72 100644 --- a/client/src/services/messageService.js +++ b/client/src/services/messageService.js @@ -3,11 +3,16 @@ import api from '../lib/api'; export const messageService = { async getMessages() { const response = await api.get('/messages'); - return response.data; + return response.data.messages || response.data || []; }, async sendMessage(payload) { const response = await api.post('/messages', payload); return response.data; + }, + + async toggleGlobalChat(enabled) { + const response = await api.post('/messages/toggle-global', { enabled }); + return response.data; } }; diff --git a/client/src/services/paymentService.js b/client/src/services/paymentService.js index 582800a..9925679 100644 --- a/client/src/services/paymentService.js +++ b/client/src/services/paymentService.js @@ -9,5 +9,20 @@ export const paymentService = { async initiateStkPush(payload) { const response = await api.post('/payments/stkpush', payload); return response.data; + }, + + async getPaymentSettings() { + const response = await api.get('/payments/settings'); + return response.data; + }, + + async updatePaymentSettings(payload) { + const response = await api.put('/payments/settings', payload); + return response.data; + }, + + async getPaymentMethods() { + const response = await api.get('/payments/methods'); + return response.data; } }; diff --git a/client/src/services/propertyService.js b/client/src/services/propertyService.js index 15d2c0d..9852610 100644 --- a/client/src/services/propertyService.js +++ b/client/src/services/propertyService.js @@ -34,5 +34,15 @@ export const propertyService = { async getPropertyTenants(propertyId) { const response = await api.get(`/properties/${propertyId}/tenants`); return response.data; + }, + + async deleteProperty(propertyId) { + const response = await api.delete(`/properties/${propertyId}`); + return response.data; + }, + + async createTenant(payload) { + const response = await api.post('/tenants', payload); + return response.data; } }; diff --git a/client/src/services/reminderService.js b/client/src/services/reminderService.js index 3c2e157..79db5a1 100644 --- a/client/src/services/reminderService.js +++ b/client/src/services/reminderService.js @@ -2,7 +2,25 @@ import api from '../lib/api'; export const reminderService = { async generateReminder(payload) { - const response = await api.post('/reminders/generate', payload); + const response = await api.post('/reminders/manual', payload); + return { + ...response.data, + reminderText: response.data.reminderText || response.data.message || '' + }; + }, + + async toggleAutoReminders(enabled) { + const response = await api.post('/reminders/toggle', { enabled }); + return response.data; + }, + + async updateReminderSettings(payload) { + const response = await api.post('/reminders/settings', payload); + return response.data; + }, + + async triggerAllReminders() { + const response = await api.post('/reminders/trigger-all'); return response.data; } }; diff --git a/client/src/services/repairService.js b/client/src/services/repairService.js index 26e9af1..13f5aa8 100644 --- a/client/src/services/repairService.js +++ b/client/src/services/repairService.js @@ -18,5 +18,10 @@ export const repairService = { async updateRepair(id, payload) { const response = await api.put(`/repairs/${id}`, payload); return response.data; + }, + + async deleteRepair(id) { + const response = await api.delete(`/repairs/${id}`); + return response.data; } }; diff --git a/client/src/styles/Auth.css b/client/src/styles/Auth.css index eaeaaa6..f8e707f 100644 --- a/client/src/styles/Auth.css +++ b/client/src/styles/Auth.css @@ -74,6 +74,14 @@ animation: slideUp 0.6s ease-out; } +.auth-card-login { + max-width: 460px; +} + +.auth-card-register { + max-width: 620px; +} + .auth-card-wide { max-width: none; } diff --git a/client/src/styles/Dashboard.css b/client/src/styles/Dashboard.css index 562dc87..fdd359f 100644 --- a/client/src/styles/Dashboard.css +++ b/client/src/styles/Dashboard.css @@ -37,6 +37,31 @@ align-items: start; } +.workspace-mobile-bar { + display: none; +} + +.workspace-menu-toggle { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 14px; + background: rgba(15, 23, 42, 0.86); + color: white; + min-height: 44px; + padding: 0 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.55rem; + font-weight: 700; + cursor: pointer; +} + +.workspace-mobile-label { + color: var(--text-main); + font-weight: 700; + font-size: 0.95rem; +} + .workspace-sidebar { padding: 20px; position: sticky; @@ -44,6 +69,8 @@ display: flex; flex-direction: column; gap: 18px; + max-height: calc(100vh - 116px); + overflow: auto; background: linear-gradient(180deg, rgba(7, 15, 30, 0.98), rgba(15, 23, 42, 0.84)), linear-gradient(135deg, rgba(56, 189, 248, 0.08), rgba(16, 185, 129, 0.05)); @@ -167,6 +194,7 @@ .workspace-main { min-width: 0; + scroll-margin-top: 108px; } .activity-log-section { @@ -1056,8 +1084,26 @@ grid-template-columns: 1fr; } + .workspace-mobile-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + position: sticky; + top: 88px; + z-index: 25; + padding: 14px 16px; + margin-bottom: 4px; + } + .workspace-sidebar { + display: none; position: static; + max-height: none; + } + + .workspace-sidebar.is-open { + display: flex; } .workspace-nav { @@ -1100,6 +1146,15 @@ } @media (max-width: 640px) { + .workspace-mobile-bar { + flex-direction: column; + align-items: stretch; + } + + .workspace-menu-toggle { + width: 100%; + } + .workspace-nav { grid-template-columns: 1fr; } diff --git a/client/src/utils/access.js b/client/src/utils/access.js new file mode 100644 index 0000000..75fdac9 --- /dev/null +++ b/client/src/utils/access.js @@ -0,0 +1,21 @@ +import { ROLES } from './roles'; + +const ROUTE_RULES = { + '/': { roles: [ROLES.TENANT, ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN] }, + '/community': { roles: [ROLES.TENANT, ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN] }, + '/settings': { roles: [ROLES.TENANT, ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN] }, + '/change-password': { roles: [ROLES.TENANT, ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN] }, +}; + +export function canAccessPath(session, pathname) { + if (!session?.user) return false; + + // Super admin bypass or specific rule + if (session.user.role === ROLES.SUPER_ADMIN) return true; + + const rule = ROUTE_RULES[pathname]; + if (!rule) return true; // Default allow if no rule defined + + const currentRole = session.user.role; + return rule.roles.includes(currentRole); +} diff --git a/client/src/utils/roles.js b/client/src/utils/roles.js index a1e1bca..c83e31b 100644 --- a/client/src/utils/roles.js +++ b/client/src/utils/roles.js @@ -2,8 +2,6 @@ export const ROLES = Object.freeze({ TENANT: 'tenant', LANDLORD: 'landlord', PROPERTY_MANAGER: 'property_manager', - CARETAKER: 'caretaker', - AGENT: 'agent', SUPER_ADMIN: 'super_admin' }); @@ -13,31 +11,15 @@ export const MANAGEMENT_ROLES = [ ROLES.SUPER_ADMIN ]; -export const STAFF_ROLES = [ - ROLES.CARETAKER, - ROLES.AGENT -]; - export const SELF_SERVICE_REGISTRATION_OPTIONS = [ { value: ROLES.TENANT, label: 'Tenant', - description: 'Vacant unit' - }, - { - value: ROLES.LANDLORD, - label: 'Landlord', - description: 'Own portfolio' - }, - { - value: ROLES.PROPERTY_MANAGER, - label: 'Property Manager', - description: 'Managed portfolio' + description: 'Apply for a vacant unit' } ]; export const isManagementRole = (role) => MANAGEMENT_ROLES.includes(role); -export const isStaffRole = (role) => STAFF_ROLES.includes(role); export const isTenantRole = (role) => role === ROLES.TENANT; export const formatRoleLabel = (role) => ( diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..39e17d1 --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const backendTarget = process.env.VITE_BACKEND_URL || 'http://127.0.0.1:5000'; + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: backendTarget, + changeOrigin: true + }, + '/uploads': { + target: backendTarget, + changeOrigin: true + } + } + } +}); diff --git a/compose.yaml b/compose.yaml index be93d55..bc5fd36 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,13 +1,17 @@ services: - mongo: - image: mongo:7 + postgres: + image: postgres:16 restart: unless-stopped ports: - - "${MONGO_PORT:-27017}:27017" + - "${DB_PORT:-5432}:5432" volumes: - - mongo-data:/data/db + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${DB_NAME:-apex_rentals} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-apex_rentals}"] interval: 10s timeout: 5s retries: 5 @@ -18,13 +22,18 @@ services: context: ./server restart: unless-stopped depends_on: - mongo: + postgres: condition: service_healthy environment: NODE_ENV: production PORT: 5000 - MONGODB_URI: mongodb://mongo:27017/${MONGO_DB:-apex_rentals} + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-apex_rentals} + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} JWT_SECRET: ${JWT_SECRET:-local-dev-jwt-secret} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d} GEMINI_API_KEY: ${GEMINI_API_KEY:-} EMAIL_HOST: ${EMAIL_HOST:-} EMAIL_USER: ${EMAIL_USER:-} @@ -33,7 +42,12 @@ services: MPESA_CONSUMER_SECRET: ${MPESA_CONSUMER_SECRET:-} MPESA_SHORTCODE: ${MPESA_SHORTCODE:-} MPESA_PASSKEY: ${MPESA_PASSKEY:-} - MPESA_CALLBACK_URL: ${MPESA_CALLBACK_URL:-http://localhost:5000/api/payments/callback} + MPESA_CALLBACK_URL: ${MPESA_CALLBACK_URL:-http://localhost:5001/api/payments/callback} + CLOUDINARY_CLOUD_NAME: ${CLOUDINARY_CLOUD_NAME:-} + CLOUDINARY_API_KEY: ${CLOUDINARY_API_KEY:-} + CLOUDINARY_API_SECRET: ${CLOUDINARY_API_SECRET:-} + PAYSTACK_SECRET_KEY: ${PAYSTACK_SECRET_KEY:-} + FRONTEND_URL: ${FRONTEND_URL:-http://localhost:8081} ports: - "${SERVER_PORT:-5000}:5000" volumes: @@ -52,6 +66,8 @@ services: depends_on: server: condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" ports: - "${CLIENT_PORT:-8081}:80" healthcheck: @@ -62,5 +78,5 @@ services: start_period: 10s volumes: - mongo-data: + postgres-data: server-uploads: diff --git a/server/ .env.example b/server/ .env.example new file mode 100644 index 0000000..7a6d7ca --- /dev/null +++ b/server/ .env.example @@ -0,0 +1,37 @@ +# --- SERVER CONFIG --- +PORT=5000 +NODE_ENV=development +JWT_SECRET=apex_rentals_secret_key_123 + +# --- DATABASE CONFIG --- +DB_HOST=localhost +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=apex_rentals +DB_PORT=5432 + +# --- CLOUDINARY (Images/Documents) --- +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret + +# --- PAYSTACK (Platform Subscriptions) --- +PAYSTACK_SECRET_KEY=your_paystack_secret_key + +# --- MPESA (Platform Fallback / Testing) --- +MPESA_CONSUMER_KEY=your_key +MPESA_CONSUMER_SECRET=your_secret +MPESA_SHORTCODE=your_shortcode +MPESA_PASSKEY=your_passkey +MPESA_CALLBACK_URL=https://your-domain.com/api/payments/callback + +# --- AI (Google Gemini) --- +GEMINI_API_KEY=your_gemini_api_key + +# --- EMAIL (Nodemailer) --- +EMAIL_HOST=smtp.gmail.com +EMAIL_USER=your_email@gmail.com +EMAIL_PASS=your_app_password + +# --- FRONTEND --- +FRONTEND_URL=http://localhost:3000 diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..ed5797d --- /dev/null +++ b/server/README.md @@ -0,0 +1,55 @@ +# Apex Rentals Management Solution - Backend + +A multi-tenant Rental Management system built with Node.js, Express, and PostgreSQL. + +## Features +- **Multi-Tenancy:** Isolated data schemas per landlord/organization. +- **Tenant Onboarding:** Inquiry and approval workflow with automated temporary password generation. +- **Payments:** Direct-to-Landlord M-Pesa integration and Paystack for platform subscriptions. +- **Messaging:** Property-specific and portfolio-wide community chats. +- **Maintenance:** Repair requests with image uploads and status tracking. +- **Automation:** Automated rent reminders and billing cycles. + +## Local Setup + +### Prerequisites +- Node.js (v18+) +- PostgreSQL +- Paystack Account (for subscriptions) +- Safaricom Daraja Account (for M-Pesa) +- Gemini API Key (for AI reminders) + +### Installation +1. Clone the repository. +2. Install dependencies: + ```bash + npm install + ``` +3. Create a `.env` file in the root directory (use `.env.example` as a template). + +### Database Configuration +Ensure PostgreSQL is running and create a database named `apex_rentals`. +Run the startup command to automatically apply public migrations: +```bash +npm start +``` + +### Seeding Test Data +To quickly start testing, run the super admin seed: +```bash +npm run seed:admin +``` + +## API Documentation +Detailed API documentation can be found in [docs/API_DOCUMENTATION.md](./docs/API_DOCUMENTATION.md). + +## Running with Docker +```bash +docker build -t apex-backend . +docker run -p 5000:5000 --env-file .env apex-backend +``` + +## Testing with Postman +1. Import the collection from `docs/Apex_Agencies.postman_collection.json`. +2. Set the `baseUrl` variable to `http://localhost:5000/api`. +3. Use the login endpoint to get a token and set it as a Bearer token for other requests. diff --git a/server/app.js b/server/app.js index 0751fb3..aa439e6 100644 --- a/server/app.js +++ b/server/app.js @@ -2,10 +2,10 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); const cors = require('cors'); -const mongoose = require('mongoose'); const apiRoutes = require('./routes'); const { uploadsRoot } = require('./config/env'); const { errorHandler, notFound } = require('./middleware/errorHandler'); +const { query } = require('./config/database'); const app = express(); @@ -21,13 +21,13 @@ app.get('/', (req, res) => { res.send('Apex Agencies Backend API is running...'); }); -app.get('/health', (req, res) => { - const isReady = mongoose.connection.readyState === 1; - - res.status(isReady ? 200 : 503).json({ - status: isReady ? 'ok' : 'degraded', - mongodbState: mongoose.connection.readyState - }); +app.get('/health', async (req, res) => { + try { + await query('SELECT 1'); + res.status(200).json({ status: 'ok', db: 'ready' }); + } catch (error) { + res.status(503).json({ status: 'degraded', db: 'unreachable' }); + } }); app.use('/api', apiRoutes); diff --git a/server/config/database.js b/server/config/database.js index 93b3cef..7d63a6a 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -1,8 +1,107 @@ -const mongoose = require('mongoose'); +const { Pool } = require('pg'); +const env = require('./env'); -const connectDatabase = (uri) => mongoose.connect(uri); +const pool = new Pool({ + host: env.db.host, + port: env.db.port, + database: env.db.database, + user: env.db.user, + password: env.db.password, + min: env.db.poolMin, + max: env.db.poolMax, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000 +}); + +pool.on('error', (err) => { + console.error('Unexpected DB pool error', err); +}); + +const IDENTIFIER_RE = /^[a-z_][a-z0-9_]*$/; + +const quoteIdentifier = (identifier) => { + const value = String(identifier || ''); + if (!IDENTIFIER_RE.test(value) || value.length > 63 || value === 'public') { + throw new Error(`Invalid database identifier: ${value || ''}`); + } + return `"${value}"`; +}; + +const query = async (text, params = [], client = null) => { + const executor = client || pool; + return executor.query(text, params); +}; + +const tenantQuery = async (schemaName, text, params = []) => { + const client = await pool.connect(); + const quotedSchema = quoteIdentifier(schemaName); + let inTransaction = false; + try { + await client.query('BEGIN'); + inTransaction = true; + await client.query(`SET LOCAL search_path TO ${quotedSchema}, public`); + const result = await client.query(text, params); + await client.query('COMMIT'); + inTransaction = false; + return result; + } catch (err) { + if (inTransaction) { + await client.query('ROLLBACK').catch(() => {}); + } + throw err; + } finally { + client.release(); + } +}; + +const transaction = async (callback) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +}; + +const tenantTransaction = async (schemaName, callback) => { + const client = await pool.connect(); + const quotedSchema = quoteIdentifier(schemaName); + let inTransaction = false; + try { + await client.query('BEGIN'); + inTransaction = true; + await client.query(`SET LOCAL search_path TO ${quotedSchema}, public`); + const result = await callback(client); + await client.query('COMMIT'); + inTransaction = false; + return result; + } catch (err) { + if (inTransaction) { + await client.query('ROLLBACK').catch(() => {}); + } + throw err; + } finally { + client.release(); + } +}; + +const connectDatabase = async () => { + const client = await pool.connect(); + client.release(); +}; module.exports = { - mongoose, + pool, + quoteIdentifier, + query, + tenantQuery, + transaction, + tenantTransaction, connectDatabase }; diff --git a/server/config/env.js b/server/config/env.js index f086bca..03ecc94 100644 --- a/server/config/env.js +++ b/server/config/env.js @@ -9,6 +9,27 @@ module.exports = { rootDir, uploadsRoot: path.join(rootDir, 'uploads'), port: process.env.PORT || 5000, - mongodbUri: process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/apex_rentals', - jwtSecret: process.env.JWT_SECRET || 'local-dev-jwt-secret' + db: { + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_NAME || 'apex_rentals', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || '', + poolMin: Number(process.env.DB_POOL_MIN || 2), + poolMax: Number(process.env.DB_POOL_MAX || 20) + }, + jwtSecret: process.env.JWT_SECRET || 'local-dev-jwt-secret', + jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d', + rateLimit: { + windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS || 900000), + max: Number(process.env.RATE_LIMIT_MAX || 100) + }, + cloudinary: { + cloudName: process.env.CLOUDINARY_CLOUD_NAME, + apiKey: process.env.CLOUDINARY_API_KEY, + apiSecret: process.env.CLOUDINARY_API_SECRET + }, + paystack: { + secretKey: process.env.PAYSTACK_SECRET_KEY + } }; diff --git a/server/controllers/adminController.js b/server/controllers/adminController.js index 9ec9afe..7c642b9 100644 --- a/server/controllers/adminController.js +++ b/server/controllers/adminController.js @@ -7,27 +7,30 @@ const { Unit, User } = require('../models'); +const { sendError } = require('../helpers/apiResponse'); const getAdminSummary = async (req, res) => { - const [ - organizations, - landlords, - tenants, - activeSubscriptions, - properties, - units, - payments, - auditEvents - ] = await Promise.all([ - Organization.countDocuments(), - User.countDocuments({ role: 'landlord' }), - User.countDocuments({ role: 'tenant' }), - Subscription.countDocuments({ status: { $in: ['trial', 'active'] } }), - Property.countDocuments(), - Unit.countDocuments({ isActive: true }), - Payment.countDocuments({ status: 'paid' }), - AuditLog.countDocuments() - ]); + const organizations = await Organization.count(); + const landlords = await User.countByRole('landlord'); + const tenants = await User.countByRole('tenant'); + const activeSubscriptions = await Subscription.countActive(); + const auditEvents = await AuditLog.count(); + + const orgs = await Organization.findAll(); + let properties = 0; + let units = 0; + let payments = 0; + + for (const org of orgs) { + if (!org.schema_name) continue; + try { + properties += await Property.count(org.schema_name); + units += await Unit.countActive(org.schema_name); + payments += await Payment.countPaid(org.schema_name); + } catch (error) { + continue; + } + } res.json({ organizations, @@ -42,9 +45,7 @@ const getAdminSummary = async (req, res) => { }; const getOrganizations = async (req, res) => { - const organizations = await Organization.find() - .populate('owner', 'name email role') - .sort({ createdAt: -1 }); + const organizations = await Organization.findAll(); res.json(organizations); }; @@ -52,17 +53,73 @@ const getOrganizations = async (req, res) => { const getAuditLogs = async (req, res) => { const limit = Math.min(Math.max(Number(req.query.limit || 60), 1), 120); - const auditLogs = await AuditLog.find() - .populate('actor', 'name email role') - .populate('organization', 'name status subscriptionPlan') - .sort({ createdAt: -1 }) - .limit(limit); + const auditLogs = await AuditLog.list(limit); res.json(auditLogs); }; +const getPendingUsers = async (req, res) => { + const pendingUsers = await User.findPending(); + res.json(pendingUsers.map(User.sanitize)); +}; + +const approveUser = async (req, res) => { + const { userId } = req.params; + const user = await User.findById(userId); + + if (!user) { + sendError(res, 404, 'User not found'); + return; + } + + const updatedUser = await User.update(user.id, { + status: 'active', + is_active: true + }); + + res.json({ + message: 'User approved successfully', + user: User.sanitize(updatedUser) + }); +}; + +const updateOrganizationBilling = async (req, res) => { + const { id } = req.params; + const { pricePerUnit, billingCycleMonths, status } = req.body; + + const updates = {}; + if (pricePerUnit !== undefined) updates.price_per_unit = pricePerUnit; + if (billingCycleMonths !== undefined) updates.billing_cycle_months = billingCycleMonths; + if (status !== undefined) updates.status = status; + + const organization = await Organization.update(id, updates); + + // If the admin manually activates the organization, reset the subscription dates + if (status === 'active' || status === 'trial') { + const sub = await Subscription.findByOrganization(id); + if (sub) { + const nextDate = new Date(); + nextDate.setMonth(nextDate.getMonth() + (organization.billing_cycle_months || 1)); + + await Subscription.update(sub.id, { + status: status, + next_billing_date: nextDate, + last_billed_at: new Date() + }); + } + } + + res.json({ + message: 'Organization billing updated', + organization + }); +}; + module.exports = { getAdminSummary, getOrganizations, - getAuditLogs + getAuditLogs, + getPendingUsers, + approveUser, + updateOrganizationBilling }; diff --git a/server/controllers/authController.js b/server/controllers/authController.js index f9a83fd..e7ab089 100644 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -1,45 +1,203 @@ const jwt = require('jsonwebtoken'); -const { Organization, Property, Tenant, User } = require('../models'); +const crypto = require('crypto'); +const { Organization, OrganizationMembership, Property, Tenant, User, Subscription } = require('../models'); const { logAudit } = require('../helpers/audit'); -const { jwtSecret } = require('../config/env'); +const { jwtSecret, jwtExpiresIn } = require('../config/env'); const { sendError } = require('../helpers/apiResponse'); const { ROLES } = require('../helpers/rbac'); +const { transaction } = require('../config/database'); +const { sendPaymentConfirmation, notifyLandlord, sendAccountCreatedEmail } = require('../services/emailService'); +const { createTenantSchema } = require('../services/tenantService'); const managerRoles = [ROLES.LANDLORD, ROLES.PROPERTY_MANAGER]; -const buildUserResponse = (user) => ({ - id: user._id, +const buildUserResponse = (user, organizationId = user?.organization_id || null) => ({ + id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, - phoneNumber: user.phoneNumber, - interestedProperty: user.interestedProperty, - interestedUnit: user.interestedUnit, - organization: user.organization + phoneNumber: user.phone_number, + interestedProperty: user.interested_property_id, + interestedUnit: user.interested_unit, + organization: organizationId, + requiresPasswordChange: user.requires_password_change }); +const resolveOrganizationContext = async (user, requestedOrganizationId = null) => { + let memberships = await OrganizationMembership.listByUser(user.id); + + if (!memberships.length && user.organization_id) { + await OrganizationMembership.create({ + userId: user.id, + organizationId: user.organization_id, + isDefault: true + }); + memberships = await OrganizationMembership.listByUser(user.id); + } + + let activeOrganization = null; + if (requestedOrganizationId) { + activeOrganization = memberships.find((membership) => + membership.organization_id === requestedOrganizationId || membership.id === requestedOrganizationId + ); + if (!activeOrganization) { + throw new Error('This account does not belong to the selected organization'); + } + } else { + activeOrganization = memberships.find((membership) => + membership.is_default && ['trial', 'active'].includes(membership.status) + ) || memberships.find((membership) => + ['trial', 'active'].includes(membership.status) + ) || memberships.find((membership) => membership.is_default) || memberships[0] || null; + } + + if (!activeOrganization) { + throw new Error('No organization membership found for this account'); + } + + if (!['trial', 'active'].includes(activeOrganization.status)) { + throw new Error('Selected organization account is not active'); + } + + await OrganizationMembership.setDefault(user.id, activeOrganization.organization_id); + memberships = await OrganizationMembership.listByUser(user.id); + activeOrganization = memberships.find((membership) => + membership.organization_id === activeOrganization.organization_id + ) || activeOrganization; + + return { + activeOrganization, + organizations: memberships + }; +}; + const getAvailableProperties = async (req, res) => { - const properties = await Property.find(); - - const availableProperties = await Promise.all(properties.map(async (property) => { - const activeTenants = await Tenant.find({ property: property._id, status: 'active' }); - const occupiedUnits = activeTenants.map((tenant) => tenant.unit); - const unoccupiedUnits = (property.units || []).filter((unit) => !occupiedUnits.includes(unit)); - - return { - _id: property._id, - name: property.name, - address: property.address, - units: unoccupiedUnits - }; - })); - - res.json(availableProperties.filter((property) => property.units.length > 0)); + const organizations = await Organization.findAll(); + const availableProperties = []; + + for (const organization of organizations) { + if (!organization.schema_name || !['trial', 'active'].includes(organization.status)) continue; + + // If user is logged in as a tenant/manager, only show properties from their organization + if (req.organizationId && organization.id !== req.organizationId) { + continue; + } + + let properties = []; + try { + properties = await Property.find(organization.schema_name); + } catch (error) { + continue; + } + + for (const property of properties) { + const activeTenants = await Tenant.find(organization.schema_name, { + propertyId: property.id, + status: 'active' + }); + const occupiedUnits = activeTenants.map((tenant) => tenant.unit); + const unoccupiedUnits = (property.units || []).filter((unit) => !occupiedUnits.includes(unit)); + + if (unoccupiedUnits.length) { + availableProperties.push({ + _id: property.id, + name: property.name, + address: property.address, + units: unoccupiedUnits, + organizationId: organization.id + }); + } + } + } + + res.json(availableProperties); +}; + +const submitInquiry = async (req, res) => { + const { + name, + email, + phoneNumber, + interestedProperty, + interestedUnit, + organizationId + } = req.body; + + const existingUser = await User.findByEmail(email); + if (existingUser) { + sendError(res, 400, 'User with this email already exists. Please login to apply or use a different email.'); + return; + } + + let resolvedOrganizationId = organizationId; + let resolvedSchema = null; + + if (interestedProperty) { + const orgs = await Organization.findAll(); + for (const org of orgs) { + if (!['trial', 'active'].includes(org.status)) continue; + const property = await Property.findById(org.schema_name, interestedProperty); + if (property) { + resolvedOrganizationId = org.id; + resolvedSchema = org.schema_name; + break; + } + } + } + + if (!resolvedOrganizationId) { + sendError(res, 404, 'The selected property was not found or is no longer available.'); + return; + } + + // Generate a random placeholder password + const placeholderPassword = crypto.randomBytes(16).toString('hex'); + + const user = await User.create({ + organizationId: resolvedOrganizationId, + name, + email, + password: placeholderPassword, + phoneNumber, + role: ROLES.TENANT, + status: 'pending', + interestedPropertyId: interestedProperty, + interestedPropertySchema: resolvedSchema, + interestedUnit, + requiresPasswordChange: true + }); + + await logAudit({ + organization: resolvedOrganizationId, + actor: user.id, + action: 'Tenant inquiry submitted', + entityType: 'user', + entityId: user.id, + metadata: { + summary: `Unit ${interestedUnit || 'pending'} request from ${name}`, + propertyId: interestedProperty, + unit: interestedUnit + } + }); + + res.status(201).json({ + message: 'Inquiry submitted successfully. The landlord will review your request and contact you with further instructions.', + status: user.status + }); }; const register = async (req, res) => { - const { name, email, password, role, interestedProperty, interestedUnit, phoneNumber } = req.body; + const { + name, + email, + password, + role, + interestedProperty, + interestedUnit, + phoneNumber, + organizationId + } = req.body; if (!name || !email || !password || !role) { sendError(res, 400, 'Name, email, password and role are required'); @@ -51,58 +209,143 @@ const register = async (req, res) => { return; } - const existingUser = await User.findOne({ email }); - if (existingUser) { + if (role === ROLES.SUPER_ADMIN) { + sendError(res, 403, 'Super admin accounts cannot be self-registered'); + return; + } + + const existingUser = await User.findByEmail(email); + const canCreateAdditionalPortfolio = existingUser + && managerRoles.includes(role) + && managerRoles.includes(existingUser.role); + + if (existingUser && !canCreateAdditionalPortfolio) { sendError(res, 400, 'User with this email already exists'); return; } + if (canCreateAdditionalPortfolio) { + if (!existingUser.is_active || existingUser.status !== 'active') { + sendError(res, 403, 'Existing account is not active'); + return; + } + + const validExistingPassword = await User.verifyPassword(existingUser, password); + if (!validExistingPassword) { + sendError(res, 409, 'This email is already linked to an account. Sign in with the same password to add another portfolio.'); + return; + } + } + let organization = null; - let organizationId = null; + let resolvedOrganizationId = null; + let resolvedSchema = null; + let user = existingUser || null; if (managerRoles.includes(role)) { - organization = await Organization.create({ - name: `${name}'s Portfolio`, - status: 'trial', - subscriptionPlan: 'basic', - billingCycle: 'monthly' + await transaction(async (client) => { + organization = await Organization.create({ + name: `${name}'s Portfolio`, + status: 'trial', + subscriptionPlan: 'basic', + billingCycle: 'monthly' + }, client); + + await Subscription.create({ + organizationId: organization.id, + plan: 'basic', + billingCycle: 'monthly', + status: 'trial' + }, client); + + if (user) { + user = await User.update(user.id, { + organization_id: organization.id + }, client); + } else { + user = await User.create({ + organizationId: organization.id, + name, + email, + password, + phoneNumber, + role, + status: 'active' + }, client); + } + + await Organization.update(organization.id, { owner_id: user.id }, client); + await OrganizationMembership.create({ + userId: user.id, + organizationId: organization.id, + isDefault: true + }, client); }); - organizationId = organization._id; + + await createTenantSchema(organization.schema_name); + resolvedOrganizationId = organization.id; + resolvedSchema = organization.schema_name; } else if (interestedProperty) { - const property = await Property.findById(interestedProperty).select('organization'); - organizationId = property?.organization || null; + if (organizationId) { + const org = await Organization.findById(organizationId); + if (!org) { + sendError(res, 404, 'Organization not found'); + return; + } + if (!['trial', 'active'].includes(org.status)) { + sendError(res, 403, 'Organization account is not active'); + return; + } + const property = await Property.findById(org.schema_name, interestedProperty); + if (!property) { + sendError(res, 404, 'Property not found'); + return; + } + resolvedOrganizationId = org.id; + resolvedSchema = org.schema_name; + } else { + const organizations = await Organization.findAll(); + for (const org of organizations) { + if (!['trial', 'active'].includes(org.status)) continue; + const property = await Property.findById(org.schema_name, interestedProperty); + if (property) { + resolvedOrganizationId = org.id; + resolvedSchema = org.schema_name; + break; + } + } + } } - const status = managerRoles.includes(role) ? 'active' : 'pending'; - const user = new User({ - organization: organizationId, - name, - email, - password, - phoneNumber, - role, - status, - interestedProperty: role === ROLES.TENANT ? interestedProperty : undefined, - interestedUnit: role === ROLES.TENANT ? interestedUnit : undefined - }); - - await user.save(); + if (role === ROLES.TENANT && interestedProperty && !resolvedOrganizationId) { + sendError(res, 404, 'Property not found'); + return; + } - if (organization) { - organization.owner = user._id; - await organization.save(); + const status = 'pending'; + if (!user) { + user = await User.create({ + organizationId: resolvedOrganizationId, + name, + email, + password, + phoneNumber, + role, + status, + interestedPropertyId: role === ROLES.TENANT ? interestedProperty : null, + interestedPropertySchema: role === ROLES.TENANT ? resolvedSchema : null, + interestedUnit: role === ROLES.TENANT ? interestedUnit : null + }); } - const message = managerRoles.includes(role) - ? 'Account created successfully' - : 'Registration request submitted. Waiting for approval.'; + const message = 'Registration request submitted. Waiting for approval.'; await logAudit({ - organization: organizationId, - actor: user._id, + organization: resolvedOrganizationId, + actor: user.id, action: managerRoles.includes(role) ? 'Account registered' : 'Tenant application submitted', entityType: 'user', - entityId: user._id, + entityId: user.id, metadata: { role, status, @@ -117,31 +360,55 @@ const register = async (req, res) => { res.status(201).json({ message, status: user.status, - organizationId + organizationId: resolvedOrganizationId }); }; const login = async (req, res) => { - const { email, password } = req.body; - - const user = await User.findOne({ email, password }) - .populate('interestedProperty', 'name address') - .populate('organization', 'name subscriptionPlan status'); + const { email, password, organizationId } = req.body; + const user = await User.findByEmail(email); if (!user) { sendError(res, 401, 'Invalid email or password'); return; } - user.lastLoginAt = new Date(); - await user.save(); + const validPassword = await User.verifyPassword(user, password); + if (!validPassword) { + sendError(res, 401, 'Invalid email or password'); + return; + } + + if (user.status !== 'active' || !user.is_active) { + sendError(res, 403, 'Account is not active'); + return; + } + + let activeOrganizationId = user.organization_id; + let activeOrganization = null; + let organizations = []; + if (user.role !== ROLES.SUPER_ADMIN) { + try { + const context = await resolveOrganizationContext(user, organizationId || null); + activeOrganization = context.activeOrganization; + organizations = context.organizations; + activeOrganizationId = activeOrganization.organization_id; + } catch (error) { + sendError(res, 403, error.message); + return; + } + } else { + activeOrganizationId = null; + } + + await User.update(user.id, { last_login_at: new Date() }); await logAudit({ - organization: user.organization?._id || user.organization || null, - actor: user._id, + organization: activeOrganizationId || null, + actor: user.id, action: 'User signed in', entityType: 'session', - entityId: user._id, + entityId: user.id, metadata: { role: user.role, summary: user.email @@ -149,35 +416,137 @@ const login = async (req, res) => { }); const token = jwt.sign({ - id: user._id.toString(), + id: user.id, role: user.role, - organizationId: user.organization?._id?.toString() || user.organization?.toString() || null - }, jwtSecret); + organizationId: activeOrganizationId || null + }, jwtSecret, { expiresIn: jwtExpiresIn }); res.json({ token, - user: buildUserResponse(user) + user: buildUserResponse(user, activeOrganizationId), + organizationId: activeOrganizationId, + organization: activeOrganization, + organizations }); }; const getCurrentUser = async (req, res) => { - const user = await User.findById(req.user.id) - .populate('interestedProperty', 'name address') - .populate('organization', 'name subscriptionPlan status'); + const user = await User.findById(req.user.id); if (!user) { sendError(res, 404, 'User not found'); return; } + const organizations = user.role === ROLES.SUPER_ADMIN + ? [] + : await OrganizationMembership.listByUser(user.id); + const organization = req.organizationId + ? organizations.find((membership) => membership.organization_id === req.organizationId) || null + : null; + res.json({ - user: buildUserResponse(user) + user: buildUserResponse(user, req.organizationId), + organization, + organizations }); }; +const getOrganizationStaff = async (req, res) => { + const staff = await OrganizationMembership.listByOrganization(req.organizationId); + res.json(staff.map((s) => ({ + id: s.user_id, + name: s.name, + email: s.email, + role: s.role, + status: s.status, + phoneNumber: s.phone_number + }))); +}; + +const addOrganizationStaff = async (req, res) => { + const { name, email, password, role, phoneNumber } = req.body; + + if (role === ROLES.SUPER_ADMIN || role === ROLES.LANDLORD) { + sendError(res, 403, 'Cannot add another landlord or super admin to the organization'); + return; + } + + const existingUser = await User.findByEmail(email); + if (existingUser) { + sendError(res, 400, 'User with this email already exists'); + return; + } + + const user = await User.create({ + organizationId: req.organizationId, + name, + email, + password, + phoneNumber, + role, + status: 'active', // Staff added by landlord are active immediately + requiresPasswordChange: true + }); + + await OrganizationMembership.create({ + userId: user.id, + organizationId: req.organizationId, + isDefault: true + }); + + // Send the welcome email with the password + await sendAccountCreatedEmail(email, name, password, role); + + res.status(201).json(buildUserResponse(user)); +}; + +const changePassword = async (req, res) => { + const { currentPassword, newPassword } = req.body; + + if (!currentPassword || !newPassword) { + sendError(res, 400, 'Both current and new passwords are required'); + return; + } + + const user = await User.findById(req.user.id); + if (!user) { + sendError(res, 404, 'User not found'); + return; + } + + const validPassword = await User.verifyPassword(user, currentPassword); + if (!validPassword) { + sendError(res, 401, 'Current password is incorrect'); + return; + } + + await User.update(user.id, { + password: newPassword, + requires_password_change: false + }); + + await logAudit({ + organization: req.organizationId || null, + actor: user.id, + action: 'Password changed', + entityType: 'user', + entityId: user.id, + metadata: { + summary: 'Forced password change completed' + } + }); + + res.json({ message: 'Password updated successfully' }); +}; + module.exports = { getAvailableProperties, getCurrentUser, + getOrganizationStaff, + addOrganizationStaff, register, - login + login, + changePassword, + submitInquiry }; diff --git a/server/controllers/billingController.js b/server/controllers/billingController.js new file mode 100644 index 0000000..1f84af2 --- /dev/null +++ b/server/controllers/billingController.js @@ -0,0 +1,47 @@ +const { Organization, Subscription, User } = require('../models'); +const { generatePaystackLink, countOccupiedUnits } = require('../services/billingService'); +const { sendError } = require('../helpers/apiResponse'); + +const getMyBilling = async (req, res) => { + const organization = await Organization.findById(req.organizationId); + const subscription = await Subscription.findByOrganization(req.organizationId); + const occupiedUnits = await countOccupiedUnits(req.schema); + + res.json({ + organization: { + id: organization.id, + name: organization.name, + status: organization.status, + pricePerUnit: organization.price_per_unit, + billingCycleMonths: organization.billing_cycle_months + }, + subscription, + currentUsage: { + occupiedUnits, + estimatedNextBill: occupiedUnits * Number(organization.price_per_unit) + } + }); +}; + +const initializeSubscriptionPayment = async (req, res) => { + const organization = await Organization.findById(req.organizationId); + const user = await User.findById(req.user.id); // The landlord + const occupiedUnits = await countOccupiedUnits(req.schema); + const amount = occupiedUnits * Number(organization.price_per_unit); + + if (amount <= 0) { + return res.json({ message: 'No balance due at this time.' }); + } + + const paymentUrl = await generatePaystackLink(user.email, amount, { + organizationId: organization.id, + type: 'subscription_payment' + }); + + res.json({ paymentUrl }); +}; + +module.exports = { + getMyBilling, + initializeSubscriptionPayment +}; diff --git a/server/controllers/leaseController.js b/server/controllers/leaseController.js index eeae4d9..67d7a0d 100644 --- a/server/controllers/leaseController.js +++ b/server/controllers/leaseController.js @@ -1,22 +1,18 @@ -const path = require('path'); -const { Lease, Property } = require('../models'); +const { Lease, Property, Tenant, User } = require('../models'); const { logRequestAudit } = require('../helpers/audit'); -const { rootDir } = require('../config/env'); const { sendError } = require('../helpers/apiResponse'); const { createNotification } = require('../helpers/notifications'); -const { toStoredUploadPath } = require('../helpers/upload'); +const { deleteFromCloudinary } = require('../helpers/upload'); const { ROLES } = require('../helpers/rbac'); -const buildManagedPropertyQuery = (user) => { +const buildManagedPropertyFilter = (user) => { if (user.role === ROLES.SUPER_ADMIN) { return {}; } return { - $or: [ - { landlord: user.id }, - { manager: user.id } - ] + landlordId: user.id, + managerId: user.id }; }; @@ -28,22 +24,38 @@ const uploadLease = async (req, res) => { return; } - const property = await Property.findOne({ - _id: propertyId, - ...buildManagedPropertyQuery(req.user) - }); + const property = await Property.findById(req.schema, propertyId); if (!property) { sendError(res, 403, 'Unauthorized to upload a lease for this property'); return; } - const lease = new Lease({ - organization: property.organization, - tenant: tenantId, - property: propertyId, + if ( + req.user.role !== ROLES.SUPER_ADMIN + && property.landlord_id !== req.user.id + && property.manager_id !== req.user.id + ) { + sendError(res, 403, 'Unauthorized to upload a lease for this property'); + return; + } + + const tenantProfile = await Tenant.findOne(req.schema, { + userId: tenantId, + propertyId, unit, - filePath: toStoredUploadPath(req.file.path), + status: 'active' + }); + if (!tenantProfile) { + sendError(res, 404, 'Tenant profile not found for this organization'); + return; + } + + const lease = await Lease.create(req.schema, { + tenantId, + propertyId, + unit, + filePath: req.file.path, // req.file.path is the Cloudinary URL fileName: req.file.originalname, startDate, endDate, @@ -51,11 +63,9 @@ const uploadLease = async (req, res) => { penaltyTerms }); - await lease.save(); - await createNotification({ - organization: property.organization, - user: tenantId, + schema: req.schema, + userId: tenantId, title: 'New lease uploaded', message: `A lease agreement has been uploaded for unit ${unit}.`, type: 'lease_expiry' @@ -63,10 +73,10 @@ const uploadLease = async (req, res) => { await logRequestAudit({ req, - organization: property.organization, + organization: req.organizationId, action: 'Lease uploaded', entityType: 'lease', - entityId: lease._id, + entityId: lease.id, metadata: { propertyName: property.name, summary: `${property.name} • Unit ${unit}` @@ -77,58 +87,112 @@ const uploadLease = async (req, res) => { }; const getTenantLease = async (req, res) => { - const lease = await Lease.findOne({ tenant: req.user.id }).populate('property', 'name address'); - res.json(lease); + const lease = await Lease.findByTenant(req.schema, req.user.id); + if (!lease) { + res.json(null); + return; + } + const property = await Property.findById(req.schema, lease.property_id); + res.json({ + ...lease, + property: property ? { id: property.id, name: property.name, address: property.address } : null + }); }; const getLandlordLeases = async (req, res) => { - const properties = await Property.find(buildManagedPropertyQuery(req.user)); - const propertyIds = properties.map((property) => property._id); - - const leases = await Lease.find({ property: { $in: propertyIds } }) - .populate('tenant', 'name email') - .populate('property', 'name'); - - res.json(leases); + const properties = await Property.find(req.schema, buildManagedPropertyFilter(req.user)); + const propertyIds = properties.map((property) => property.id); + const propertyMap = new Map(properties.map((property) => [property.id, property])); + + const leases = await Lease.findByPropertyIds(req.schema, propertyIds); + const tenantIds = leases.map((lease) => lease.tenant_id); + const users = await User.findByIds(tenantIds); + const userMap = new Map(users.map((user) => [user.id, user])); + + res.json(leases.map((lease) => ({ + ...lease, + tenant: userMap.get(lease.tenant_id) + ? { id: userMap.get(lease.tenant_id).id, name: userMap.get(lease.tenant_id).name, email: userMap.get(lease.tenant_id).email } + : null, + property: propertyMap.get(lease.property_id) + ? { id: propertyMap.get(lease.property_id).id, name: propertyMap.get(lease.property_id).name } + : null + }))); }; const viewLease = async (req, res) => { - const lease = await Lease.findById(req.params.id); + const lease = await Lease.findById(req.schema, req.params.id); if (!lease) { sendError(res, 404, 'Lease not found'); return; } - const property = await Property.findById(lease.property); + const property = await Property.findById(req.schema, lease.property_id); if (!property) { sendError(res, 404, 'Property not found'); return; } - if (req.user.role === ROLES.TENANT && lease.tenant.toString() !== req.user.id) { + if (req.user.role === ROLES.TENANT && lease.tenant_id !== req.user.id) { sendError(res, 403, 'Unauthorized'); return; } if ( ![ROLES.TENANT, ROLES.SUPER_ADMIN].includes(req.user.role) - && property.landlord.toString() !== req.user.id - && property.manager?.toString() !== req.user.id + && property.landlord_id !== req.user.id + && property.manager_id !== req.user.id ) { sendError(res, 403, 'Unauthorized'); return; } - const filePath = path.isAbsolute(lease.filePath) - ? lease.filePath - : path.join(rootDir, lease.filePath); + // With Cloudinary, we just redirect to the URL + if (lease.file_path.startsWith('http')) { + return res.redirect(lease.file_path); + } + + // Fallback for any old local files if they exist (though unlikely in new setup) + res.status(400).json({ error: 'Legacy local files not supported with Cloudinary redirect' }); +}; + +const deleteLease = async (req, res) => { + const lease = await Lease.findById(req.schema, req.params.id); + if (!lease) { + return sendError(res, 404, 'Lease not found'); + } + + const property = await Property.findById(req.schema, lease.property_id); + if ( + req.user.role !== ROLES.SUPER_ADMIN + && property.landlord_id !== req.user.id + && property.manager_id !== req.user.id + ) { + return sendError(res, 403, 'Unauthorized to delete this lease'); + } + + // Delete from Cloudinary + if (lease.file_path) { + await deleteFromCloudinary(lease.file_path); + } + + await Lease.delete(req.schema, req.params.id); + + await logRequestAudit({ + req, + organization: req.organizationId, + action: 'Lease deleted', + entityType: 'lease', + entityId: req.params.id + }); - res.sendFile(filePath); + res.json({ message: 'Lease deleted successfully' }); }; module.exports = { uploadLease, getTenantLease, getLandlordLeases, - viewLease + viewLease, + deleteLease }; diff --git a/server/controllers/messageController.js b/server/controllers/messageController.js index aea00d6..471f9cc 100644 --- a/server/controllers/messageController.js +++ b/server/controllers/messageController.js @@ -1,41 +1,127 @@ -const { Message, User } = require('../models'); +const { Message, User, Tenant, Property, Organization } = require('../models'); const { logRequestAudit, truncateAuditText } = require('../helpers/audit'); +const { sendError } = require('../helpers/apiResponse'); +const { ROLES } = require('../helpers/rbac'); + +const managementRoles = [ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN]; + +const checkChatAccess = async (req, propertyId) => { + const organization = await Organization.findById(req.organizationId); + + if (!propertyId) { + // Global chat access: + // Staff always have access. Tenants only if the landlord enabled it. + if (managementRoles.includes(req.user.role)) return true; + return organization && organization.global_chat_enabled; + } + + if (managementRoles.includes(req.user.role)) { + if (req.user.role === ROLES.SUPER_ADMIN) return true; + const property = await Property.findById(req.schema, propertyId); + return property && (property.landlord_id === req.user.id || property.manager_id === req.user.id); + } + + // For tenants, check if they live in the property + const tenant = await Tenant.findOne(req.schema, { userId: req.user.id, propertyId }); + return !!tenant; +}; const getMessages = async (req, res) => { - const currentUser = await User.findById(req.user.id).select('organization'); - const filter = currentUser?.organization ? { organization: currentUser.organization } : {}; + let propertyId = req.query.propertyId || null; + + // Defaulting logic for tenants: If they don't specify, give them their building chat + if (!propertyId && req.user.role === ROLES.TENANT) { + const tenantRecord = await Tenant.findOne(req.schema, { userId: req.user.id }); + if (tenantRecord) { + propertyId = tenantRecord.property_id; + } + } + + if (!(await checkChatAccess(req, propertyId))) { + const errorMsg = propertyId + ? 'You do not have access to this chat' + : 'The organization-wide chat is currently disabled by the landlord'; + return sendError(res, 403, errorMsg); + } + + const messages = await Message.list(req.schema, propertyId); + const senderIds = [...new Set(messages.map((message) => message.sender_id))]; + const users = await User.findByIds(senderIds); + const userMap = new Map(users.map((user) => [user.id, user])); - const messages = await Message.find(filter).populate('sender', 'name').sort({ timestamp: 1 }); - res.json(messages); + res.json({ + propertyId, + isGlobal: !propertyId, + messages: messages.map((message) => ({ + ...message, + sender: userMap.get(message.sender_id) + ? { id: userMap.get(message.sender_id).id, name: userMap.get(message.sender_id).name, role: userMap.get(message.sender_id).role } + : null + })) + }); }; const createMessage = async (req, res) => { - const currentUser = await User.findById(req.user.id).select('organization'); + let { content, propertyId } = req.body; - const message = new Message({ - organization: currentUser?.organization, - sender: req.user.id, - content: req.body.content - }); + // Defaulting logic for tenants + if (!propertyId && req.user.role === ROLES.TENANT) { + const tenantRecord = await Tenant.findOne(req.schema, { userId: req.user.id }); + if (tenantRecord) { + propertyId = tenantRecord.property_id; + } + } + + if (!(await checkChatAccess(req, propertyId))) { + const errorMsg = propertyId + ? 'You do not have permission to post in this chat' + : 'The organization-wide chat is currently disabled by the landlord'; + return sendError(res, 403, errorMsg); + } - await message.save(); + const message = await Message.create(req.schema, { + senderId: req.user.id, + content, + propertyId: propertyId || null + }); await logRequestAudit({ req, - organization: currentUser?.organization || null, - action: 'Community message sent', + organization: req.organizationId, + action: propertyId ? 'Property message sent' : 'Community message sent', entityType: 'message', - entityId: message._id, + entityId: message.id, metadata: { - summary: truncateAuditText(req.body.content, 72) + propertyId: propertyId || null, + summary: truncateAuditText(content, 72) } }); - const populatedMessage = await Message.findById(message._id).populate('sender', 'name'); - res.status(201).json(populatedMessage); + const sender = await User.findById(req.user.id); + res.status(201).json({ + ...message, + sender: sender ? { id: sender.id, name: sender.name, role: sender.role } : null + }); +}; + +const toggleGlobalChat = async (req, res) => { + if (!managementRoles.includes(req.user.role)) { + return sendError(res, 403, 'Only landlords can toggle the global chat'); + } + + const { enabled } = req.body; + const organization = await Organization.update(req.organizationId, { + global_chat_enabled: !!enabled + }); + + res.json({ + message: `Organization-wide chat ${organization.global_chat_enabled ? 'enabled' : 'disabled'}`, + globalChatEnabled: organization.global_chat_enabled + }); }; module.exports = { getMessages, - createMessage + createMessage, + toggleGlobalChat }; diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 50607a7..83eddf7 100644 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -3,17 +3,12 @@ const { logRequestAudit } = require('../helpers/audit'); const { sendError } = require('../helpers/apiResponse'); const getNotifications = async (req, res) => { - const notifications = await Notification.find({ user: req.user.id }).sort({ createdAt: -1 }); + const notifications = await Notification.findByUser(req.schema, req.user.id); res.json(notifications); }; const markNotificationRead = async (req, res) => { - const notification = await Notification.findOneAndUpdate({ - _id: req.params.id, - user: req.user.id - }, { - $set: { isRead: true } - }, { new: true }); + const notification = await Notification.markRead(req.schema, req.params.id, req.user.id); if (!notification) { sendError(res, 404, 'Notification not found'); @@ -22,10 +17,10 @@ const markNotificationRead = async (req, res) => { await logRequestAudit({ req, - organization: notification.organization || null, + organization: req.organizationId, action: 'Notification marked read', entityType: 'notification', - entityId: notification._id, + entityId: notification.id, metadata: { summary: notification.title || 'Notice read' } diff --git a/server/controllers/paymentController.js b/server/controllers/paymentController.js index 6c228af..a65cf06 100644 --- a/server/controllers/paymentController.js +++ b/server/controllers/paymentController.js @@ -1,4 +1,4 @@ -const { MpesaTransaction, Payment, Property, Tenant, User } = require('../models'); +const { MpesaTransaction, Payment, Property, Tenant, User, Organization } = require('../models'); const { logAudit, logRequestAudit } = require('../helpers/audit'); const { sendError } = require('../helpers/apiResponse'); const { createNotification } = require('../helpers/notifications'); @@ -6,32 +6,56 @@ const { initiateSTKPush } = require('../services/mpesaService'); const { notifyLandlord, sendPaymentConfirmation } = require('../services/emailService'); const { ROLES } = require('../helpers/rbac'); +const canManageProperty = (user, property) => Boolean(property) && ( + user.role === ROLES.SUPER_ADMIN + || property.landlord_id === user.id + || property.manager_id === user.id +); + const getPayments = async (req, res) => { let payments; if ([ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN].includes(req.user.role)) { - const propertyQuery = req.user.role === ROLES.SUPER_ADMIN + const filter = req.user.role === ROLES.SUPER_ADMIN ? {} - : { $or: [{ landlord: req.user.id }, { manager: req.user.id }] }; - - const properties = await Property.find(propertyQuery); - const propertyIds = properties.map((property) => property._id); - const tenants = await Tenant.find({ property: { $in: propertyIds } }); - const tenantIds = tenants.map((tenant) => tenant._id); - - payments = await Payment.find({ tenant: { $in: tenantIds } }) - .populate({ path: 'tenant', populate: { path: 'user', select: 'name email' } }) - .sort({ createdAt: -1 }); + : { landlordId: req.user.id, managerId: req.user.id }; + const properties = await Property.find(req.schema, filter); + const propertyIds = properties.map((property) => property.id); + const tenants = await Tenant.find(req.schema, { propertyIds }); + const tenantIds = tenants.map((tenant) => tenant.id); + + payments = await Payment.findByTenantIds(req.schema, tenantIds); + const tenantMap = new Map(tenants.map((tenant) => [tenant.id, tenant])); + const users = await User.findByIds(tenants.map((tenant) => tenant.user_id)); + const userMap = new Map(users.map((user) => [user.id, user])); + + payments = payments.map((payment) => { + const tenant = tenantMap.get(payment.tenant_id); + const user = tenant ? userMap.get(tenant.user_id) : null; + return { + ...payment, + tenant: tenant ? { + ...tenant, + user: user ? { id: user.id, name: user.name, email: user.email } : null + } : null + }; + }); } else { - const tenant = await Tenant.findOne({ user: req.user.id }); + const tenant = await Tenant.findOne(req.schema, { userId: req.user.id }); if (!tenant) { res.json([]); return; } - payments = await Payment.find({ tenant: tenant._id }) - .populate({ path: 'tenant', populate: { path: 'user', select: 'name email' } }) - .sort({ createdAt: -1 }); + payments = await Payment.findByTenantId(req.schema, tenant.id); + const user = await User.findById(tenant.user_id); + payments = payments.map((payment) => ({ + ...payment, + tenant: { + ...tenant, + user: user ? { id: user.id, name: user.name, email: user.email } : null + } + })); } res.json(payments); @@ -40,9 +64,9 @@ const getPayments = async (req, res) => { const initiatePaymentStkPush = async (req, res) => { const { amount, phoneNumber, tenantId } = req.body; - let tenant = await Tenant.findById(tenantId).populate('user property'); + let tenant = await Tenant.findById(req.schema, tenantId); if (!tenant) { - tenant = await Tenant.findOne({ user: tenantId }).populate('user property'); + tenant = await Tenant.findOne(req.schema, { userId: tenantId }); } if (!tenant) { @@ -50,11 +74,36 @@ const initiatePaymentStkPush = async (req, res) => { return; } - const response = await initiateSTKPush(phoneNumber, amount, `Tenant-${tenant.user?._id || tenantId}`); + const organization = await Organization.findById(req.organizationId); + const user = await User.findById(tenant.user_id); + const property = await Property.findById(req.schema, tenant.property_id); + if (!property) { + sendError(res, 404, 'Property not found'); + return; + } + + if (req.user.role === ROLES.TENANT && tenant.user_id !== req.user.id) { + sendError(res, 403, 'You can only pay for your own tenant account'); + return; + } + + if (req.user.role !== ROLES.TENANT && !canManageProperty(req.user, property)) { + sendError(res, 403, 'You do not have permission to initiate payments for this tenant'); + return; + } + + const mpesaConfig = { + mpesa_shortcode: organization.mpesa_shortcode, + mpesa_consumer_key: organization.mpesa_consumer_key, + mpesa_consumer_secret: organization.mpesa_consumer_secret, + mpesa_passkey: organization.mpesa_passkey, + mpesa_callback_url: process.env.MPESA_CALLBACK_URL // Keep platform callback for unified tracking + }; + + const response = await initiateSTKPush(phoneNumber, amount, `Tenant-${tenant.user_id || tenantId}`, mpesaConfig); - const transaction = new MpesaTransaction({ - organization: tenant.organization || tenant.property?.organization, - tenant: tenant._id, + const transaction = await MpesaTransaction.create(req.schema, { + tenantId: tenant.id, merchantRequestId: response.MerchantRequestID, checkoutRequestId: response.CheckoutRequestID, phoneNumber, @@ -62,17 +111,11 @@ const initiatePaymentStkPush = async (req, res) => { status: 'pending' }); - await transaction.save(); - - let payment = await Payment.findOne({ - tenant: tenant._id, - status: 'pending' - }).sort({ createdAt: -1 }); + let payment = await Payment.findPendingByTenant(req.schema, tenant.id); if (!payment) { - payment = new Payment({ - organization: tenant.organization || tenant.property?.organization, - tenant: tenant._id, + payment = await Payment.create(req.schema, { + tenantId: tenant.id, amount, dueDate: new Date(), status: 'pending', @@ -80,20 +123,21 @@ const initiatePaymentStkPush = async (req, res) => { }); } - payment.amount = amount; - payment.reference = response.CheckoutRequestID; - payment.mpesaTransaction = transaction._id; - payment.method = 'mpesa'; - await payment.save(); + payment = await Payment.update(req.schema, payment.id, { + amount, + reference: response.CheckoutRequestID, + mpesa_transaction_id: transaction.id, + method: 'mpesa' + }); await logRequestAudit({ req, - organization: tenant.organization || tenant.property?.organization, + organization: req.organizationId, action: 'Payment checkout started', entityType: 'payment', - entityId: payment._id, + entityId: payment.id, metadata: { - tenantName: tenant.user?.name || '', + tenantName: user?.name || '', summary: `KSh ${amount} • ${phoneNumber}`, checkoutRequestId: response.CheckoutRequestID } @@ -105,6 +149,66 @@ const initiatePaymentStkPush = async (req, res) => { }); }; +const getPaymentSettings = async (req, res) => { + if (req.user.role !== ROLES.LANDLORD && req.user.role !== ROLES.SUPER_ADMIN) { + return sendError(res, 403, 'Only landlords can access payment settings'); + } + + const organization = await Organization.findById(req.organizationId); + res.json({ + mpesaShortcode: organization.mpesa_shortcode, + mpesaConsumerKey: organization.mpesa_consumer_key, + mpesaConsumerSecret: organization.mpesa_consumer_secret ? '********' : null, + mpesaPasskey: organization.mpesa_passkey ? '********' : null, + bankDetails: organization.bank_details || {}, + paymentMethods: organization.payment_methods || ['mpesa'] + }); +}; + +const updatePaymentSettings = async (req, res) => { + if (req.user.role !== ROLES.LANDLORD && req.user.role !== ROLES.SUPER_ADMIN) { + return sendError(res, 403, 'Only landlords can update payment settings'); + } + + const { mpesaShortcode, mpesaConsumerKey, mpesaConsumerSecret, mpesaPasskey, bankDetails, paymentMethods } = req.body; + + const updates = {}; + if (mpesaShortcode !== undefined) updates.mpesa_shortcode = mpesaShortcode; + if (mpesaConsumerKey !== undefined) updates.mpesa_consumer_key = mpesaConsumerKey; + if (mpesaConsumerSecret !== undefined) updates.mpesa_consumer_secret = mpesaConsumerSecret; + if (mpesaPasskey !== undefined) updates.mpesa_passkey = mpesaPasskey; + if (bankDetails !== undefined) updates.bank_details = bankDetails; + if (paymentMethods !== undefined) updates.payment_methods = paymentMethods; + + const organization = await Organization.update(req.organizationId, updates); + + await logRequestAudit({ + req, + organization: req.organizationId, + action: 'Payment settings updated', + entityType: 'organization', + entityId: req.organizationId, + metadata: { + summary: 'Payment credentials updated' + } + }); + + res.json({ + message: 'Payment settings updated successfully', + paymentMethods: organization.payment_methods + }); +}; + +const getLandlordPaymentMethods = async (req, res) => { + const organization = await Organization.findById(req.organizationId); + res.json({ + paymentMethods: organization.payment_methods || ['mpesa'], + bankDetails: (organization.payment_methods || []).includes('bank_transfer') + ? organization.bank_details + : null + }); +}; + const handleMpesaCallback = async (req, res) => { const callbackData = req.body?.Body?.stkCallback; if (!callbackData) { @@ -113,92 +217,118 @@ const handleMpesaCallback = async (req, res) => { } const { CheckoutRequestID, ResultCode, ResultDesc, MerchantRequestID } = callbackData; - const transaction = await MpesaTransaction.findOne({ checkoutRequestId: CheckoutRequestID }); + const organizations = await Organization.findAll(); + let transaction = null; + let schemaName = null; + let organizationId = null; + + for (const org of organizations) { + if (!org.schema_name || !['trial', 'active'].includes(org.status)) continue; + let match = null; + try { + match = await MpesaTransaction.findByCheckoutRequestId(org.schema_name, CheckoutRequestID); + } catch (error) { + continue; + } + if (match) { + transaction = match; + schemaName = org.schema_name; + organizationId = org.id; + break; + } + } - if (!transaction) { + if (!transaction || !schemaName) { sendError(res, 404, 'Transaction not found'); return; } - transaction.merchantRequestId = MerchantRequestID; - transaction.resultCode = Number(ResultCode); - transaction.resultDesc = ResultDesc; + const updates = { + merchant_request_id: MerchantRequestID, + result_code: Number(ResultCode), + result_desc: ResultDesc + }; if (Number(ResultCode) === 0) { const metadata = callbackData.CallbackMetadata?.Item || []; - transaction.mpesaReceiptNumber = metadata.find((item) => item.Name === 'MpesaReceiptNumber')?.Value; - transaction.transactionDate = new Date(); - transaction.status = 'completed'; - await transaction.save(); - - const payment = await Payment.findOne({ mpesaTransaction: transaction._id }) - || await Payment.findOne({ tenant: transaction.tenant, status: 'pending' }).sort({ createdAt: -1 }); + updates.mpesa_receipt_number = metadata.find((item) => item.Name === 'MpesaReceiptNumber')?.Value || null; + updates.transaction_date = new Date(); + updates.status = 'completed'; + transaction = await MpesaTransaction.update(schemaName, transaction.id, updates); + + let payment = await Payment.findByMpesaTransactionId(schemaName, transaction.id); + if (!payment && transaction.tenant_id) { + payment = await Payment.findPendingByTenant(schemaName, transaction.tenant_id); + } if (payment) { - payment.status = 'paid'; - payment.paymentDate = new Date(); - payment.mpesaTransaction = transaction._id; - payment.reference = transaction.mpesaReceiptNumber || payment.reference; - await payment.save(); + payment = await Payment.update(schemaName, payment.id, { + status: 'paid', + payment_date: new Date(), + mpesa_transaction_id: transaction.id, + reference: transaction.mpesa_receipt_number || payment.reference + }); - const tenant = await Tenant.findById(payment.tenant).populate('user property'); - const landlord = tenant?.property?.landlord ? await User.findById(tenant.property.landlord) : null; + const tenant = transaction.tenant_id ? await Tenant.findById(schemaName, transaction.tenant_id) : null; + const tenantUser = tenant ? await User.findById(tenant.user_id) : null; + const property = tenant ? await Property.findById(schemaName, tenant.property_id) : null; + const landlord = property?.landlord_id ? await User.findById(property.landlord_id) : null; - if (tenant?.user?.email) { + if (tenantUser?.email) { await sendPaymentConfirmation( - tenant.user.email, - tenant.user.name, + tenantUser.email, + tenantUser.name, transaction.amount, - transaction.mpesaReceiptNumber + transaction.mpesa_receipt_number ); } - if (landlord?.email) { - await notifyLandlord(landlord.email, tenant.user.name, transaction.amount, tenant.unit); + if (landlord?.email && tenantUser) { + await notifyLandlord(landlord.email, tenantUser.name, transaction.amount, tenant?.unit || ''); } - if (tenant?.user?._id) { + if (tenantUser?.id) { await createNotification({ - organization: tenant.organization || tenant.property?.organization, - user: tenant.user._id, + schema: schemaName, + userId: tenantUser.id, title: 'Payment confirmed', message: `Payment of KSh ${transaction.amount} has been confirmed.`, type: 'payment_confirmation' }); } - if (landlord?._id) { + if (landlord?.id && tenantUser) { await createNotification({ - organization: tenant.organization || tenant.property?.organization, - user: landlord._id, + schema: schemaName, + userId: landlord.id, title: 'Rent payment received', - message: `${tenant.user.name} has paid KSh ${transaction.amount} for unit ${tenant.unit}.`, + message: `${tenantUser.name} has paid KSh ${transaction.amount} for unit ${tenant?.unit || ''}.`, type: 'payment_confirmation' }); } await logAudit({ - organization: tenant?.organization || tenant?.property?.organization || transaction.organization || null, - actor: tenant?.user?._id || null, + organization: organizationId, + actor: tenantUser?.id || null, action: 'Payment confirmed', entityType: 'payment', - entityId: payment._id, + entityId: payment.id, metadata: { - tenantName: tenant?.user?.name || '', - summary: `KSh ${transaction.amount} • ${tenant?.user?.name || 'tenant'}`, - reference: transaction.mpesaReceiptNumber || payment.reference + tenantName: tenantUser?.name || '', + summary: `KSh ${transaction.amount} • ${tenantUser?.name || 'tenant'}`, + reference: transaction.mpesa_receipt_number || payment.reference } }); } } else { - transaction.status = 'failed'; - await transaction.save(); + updates.status = 'failed'; + await MpesaTransaction.update(schemaName, transaction.id, updates); await logAudit({ - organization: transaction.organization || null, + organization: organizationId, action: 'Payment failed', entityType: 'payment', - entityId: transaction._id, + entityId: transaction.id, metadata: { summary: ResultDesc || 'M-Pesa callback failed', checkoutRequestId: CheckoutRequestID @@ -212,5 +342,8 @@ const handleMpesaCallback = async (req, res) => { module.exports = { getPayments, initiatePaymentStkPush, - handleMpesaCallback + handleMpesaCallback, + getPaymentSettings, + updatePaymentSettings, + getLandlordPaymentMethods }; diff --git a/server/controllers/propertyController.js b/server/controllers/propertyController.js index bd9c5c3..fe2f8ad 100644 --- a/server/controllers/propertyController.js +++ b/server/controllers/propertyController.js @@ -1,8 +1,10 @@ -const { Notification, Property, Tenant, Unit, User } = require('../models'); +const { Property, Tenant, Unit, User, OrganizationMembership } = require('../models'); +const crypto = require('crypto'); const { logRequestAudit } = require('../helpers/audit'); const { sendError } = require('../helpers/apiResponse'); -const { ensureOrganizationForUser } = require('../helpers/organization'); const { createNotification } = require('../helpers/notifications'); +const { sendAccountCreatedEmail } = require('../services/emailService'); +const { deleteFromCloudinary } = require('../helpers/upload'); const { ROLES } = require('../helpers/rbac'); const manageRoles = [ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN]; @@ -12,31 +14,68 @@ const sanitizeUnits = (units = []) => { return [...new Set(input.map((unit) => String(unit).trim()).filter(Boolean))]; }; -const buildManagedPropertyQuery = (user) => { +const buildManagedPropertyFilter = (user) => { if (user.role === ROLES.SUPER_ADMIN) { return {}; } - return { - $or: [ - { landlord: user.id }, - { manager: user.id } - ] - }; + if (user.role === ROLES.LANDLORD) { + return { landlordId: user.id }; + } + + // Property Managers only see properties assigned to them via manager_id + return { managerId: user.id }; +}; + +const canManageProperty = (user, property) => { + if (!property) return false; + + if (user.role === ROLES.SUPER_ADMIN) return true; + + if (user.role === ROLES.LANDLORD) { + // Landlord owns the property OR it belongs to their organization context + return property.landlord_id === user.id; + } + + if (user.role === ROLES.PROPERTY_MANAGER) { + // Property Manager MUST be specifically assigned to this property + return property.manager_id === user.id; + } + + return false; }; -const syncPropertyUnits = async (property, incomingUnits) => { +const mapProperty = (property) => ({ + ...property, + landlord: property.landlord_id, + manager: property.manager_id, + units: property.units || [], + images: property.images || [] +}); + +const mapTenant = (tenant, user) => ({ + ...tenant, + user: user ? { + id: user.id, + name: user.name, + email: user.email, + phoneNumber: user.phone_number, + role: user.role + } : null, + property: tenant.property_id +}); + +const syncPropertyUnits = async (schema, property, incomingUnits) => { const normalizedUnits = sanitizeUnits(incomingUnits ?? property.units); - property.units = normalizedUnits; - await property.save(); + await Property.update(schema, property.id, { units: normalizedUnits }); const [existingUnits, activeTenants] = await Promise.all([ - Unit.find({ property: property._id }), - Tenant.find({ property: property._id, status: 'active' }) + Unit.findByProperty(schema, property.id, { includeInactive: true }), + Tenant.find(schema, { propertyId: property.id, status: 'active' }) ]); - const occupiedByUnit = new Map(activeTenants.map((tenant) => [tenant.unit, tenant._id])); - const unitMap = new Map(existingUnits.map((unit) => [unit.unitNumber, unit])); + const occupiedByUnit = new Map(activeTenants.map((tenant) => [tenant.unit, tenant.id])); + const unitMap = new Map(existingUnits.map((unit) => [unit.unit_number, unit])); for (const unitNumber of normalizedUnits) { const tenantAssignment = occupiedByUnit.get(unitNumber) || null; @@ -44,9 +83,8 @@ const syncPropertyUnits = async (property, incomingUnits) => { const existingUnit = unitMap.get(unitNumber); if (!existingUnit) { - await Unit.create({ - organization: property.organization, - property: property._id, + await Unit.upsert(schema, { + propertyId: property.id, unitNumber, occupancyStatus, tenantAssignment @@ -54,28 +92,23 @@ const syncPropertyUnits = async (property, incomingUnits) => { continue; } - existingUnit.organization = property.organization; - existingUnit.isActive = true; - existingUnit.tenantAssignment = tenantAssignment; - existingUnit.occupancyStatus = occupancyStatus; - await existingUnit.save(); - } - - await Unit.updateMany({ - property: property._id, - unitNumber: { $nin: normalizedUnits } - }, { - $set: { - isActive: false, - occupancyStatus: 'vacant', - tenantAssignment: null - } - }); + await Unit.upsert(schema, { + propertyId: property.id, + unitNumber, + rentAmount: existingUnit.rent_amount, + occupancyStatus, + tenantAssignment, + meterReadings: existingUnit.meter_readings, + isActive: true + }); + } + + await Unit.deactivateMissing(schema, property.id, normalizedUnits); }; const getProperties = async (req, res) => { - const properties = await Property.find(buildManagedPropertyQuery(req.user)); - res.json(properties); + const properties = await Property.find(req.schema, buildManagedPropertyFilter(req.user)); + res.json(properties.map(mapProperty)); }; const createProperty = async (req, res) => { @@ -84,77 +117,150 @@ const createProperty = async (req, res) => { return; } - const currentUser = await User.findById(req.user.id); - const organizationId = await ensureOrganizationForUser(currentUser); - - const property = new Property({ + const property = await Property.create(req.schema, { ...req.body, - organization: organizationId, - landlord: req.user.role === ROLES.SUPER_ADMIN && req.body.landlord ? req.body.landlord : req.user.id, - manager: req.body.manager || (req.user.role === ROLES.PROPERTY_MANAGER ? req.user.id : undefined), + landlordId: req.user.role === ROLES.SUPER_ADMIN && req.body.landlord ? req.body.landlord : req.user.id, + managerId: req.body.manager || (req.user.role === ROLES.PROPERTY_MANAGER ? req.user.id : null), units: sanitizeUnits(req.body.units) }); - await property.save(); - await syncPropertyUnits(property, property.units); + await syncPropertyUnits(req.schema, property, property.units); await logRequestAudit({ req, - organization: property.organization, + organization: req.organizationId, action: 'Property created', entityType: 'property', - entityId: property._id, + entityId: property.id, metadata: { propertyName: property.name, summary: `${property.name} • ${property.units.length} units` } }); - res.status(201).json(property); + res.status(201).json(mapProperty(property)); }; const updateProperty = async (req, res) => { const propertyId = req.params.id; - const property = await Property.findOneAndUpdate( - { _id: propertyId, ...buildManagedPropertyQuery(req.user) }, - { ...req.body, units: req.body.units ? sanitizeUnits(req.body.units) : undefined }, - { new: true, runValidators: true } - ); - + const property = await Property.findById(req.schema, propertyId); if (!property) { - const exists = await Property.findById(propertyId); - if (!exists) { - sendError(res, 404, 'Property not found in database'); - return; - } + sendError(res, 404, 'Property not found in database'); + return; + } + if (!canManageProperty(req.user, property)) { sendError(res, 403, 'You do not have permission to edit this property'); return; } - if (req.body.units) { - await syncPropertyUnits(property, req.body.units); + const updates = {}; + if (req.body.name !== undefined) updates.name = req.body.name; + if (req.body.address !== undefined) updates.address = req.body.address; + if (req.body.description !== undefined) updates.description = req.body.description || null; + if (req.body.type !== undefined) updates.type = req.body.type || 'apartment'; + if (req.body.units !== undefined) updates.units = sanitizeUnits(req.body.units); + if (req.user.role === ROLES.SUPER_ADMIN && req.body.landlord !== undefined) { + updates.landlord_id = req.body.landlord || property.landlord_id; + } + if ( + req.body.manager !== undefined + && (req.user.role === ROLES.SUPER_ADMIN || property.landlord_id === req.user.id) + ) { + updates.manager_id = req.body.manager || null; + } + + const updated = await Property.update(req.schema, propertyId, updates); + + if (req.body.units !== undefined) { + await syncPropertyUnits(req.schema, updated, req.body.units); } await logRequestAudit({ req, - organization: property.organization, + organization: req.organizationId, action: 'Property updated', entityType: 'property', - entityId: property._id, + entityId: updated.id, metadata: { - propertyName: property.name, - summary: `${property.name} updated` + propertyName: updated.name, + summary: `${updated.name} updated` + } + }); + + res.json(mapProperty(updated)); +}; + +const deleteProperty = async (req, res) => { + const propertyId = req.params.id; + const property = await Property.findById(req.schema, propertyId); + + if (!property) { + sendError(res, 404, 'Property not found'); + return; + } + + if (!canManageProperty(req.user, property)) { + sendError(res, 403, 'You do not have permission to delete this property'); + return; + } + + // Delete all property images from Cloudinary + if (property.images && Array.isArray(property.images)) { + for (const imageUrl of property.images) { + await deleteFromCloudinary(imageUrl); + } + } + + // Find all leases and repair requests for this property to delete their files too + const { Lease, RepairRequest } = require('../models'); + const [leases, repairs] = await Promise.all([ + Lease.findByPropertyIds(req.schema, [propertyId]), + RepairRequest.findByPropertyIds(req.schema, [propertyId]) + ]); + + for (const lease of leases) { + if (lease.file_path) await deleteFromCloudinary(lease.file_path); + } + for (const repair of repairs) { + if (repair.image_path) await deleteFromCloudinary(repair.image_path); + } + + await Property.delete(req.schema, propertyId); + + await logRequestAudit({ + req, + organization: req.organizationId, + action: 'Property deleted', + entityType: 'property', + entityId: propertyId, + metadata: { + propertyName: property.name } }); - res.json(property); + res.json({ message: 'Property and all associated files deleted successfully' }); }; const getPropertyTenants = async (req, res) => { - const tenants = await Tenant.find({ property: req.params.id }).populate('user', 'name email phoneNumber role'); - res.json(tenants); + const property = await Property.findById(req.schema, req.params.id); + if (!property) { + sendError(res, 404, 'Property not found'); + return; + } + + if (!canManageProperty(req.user, property)) { + sendError(res, 403, 'You do not have permission to view tenants for this property'); + return; + } + + const tenants = await Tenant.find(req.schema, { propertyId: req.params.id }); + const userIds = tenants.map((tenant) => tenant.user_id); + const users = await User.findByIds(userIds); + const userMap = new Map(users.map((user) => [user.id, user])); + + res.json(tenants.map((tenant) => mapTenant(tenant, userMap.get(tenant.user_id)))); }; const createTenant = async (req, res) => { @@ -163,53 +269,82 @@ const createTenant = async (req, res) => { return; } - const property = await Property.findOne({ - _id: req.body.property, - ...buildManagedPropertyQuery(req.user) - }); + const property = await Property.findById(req.schema, req.body.property); if (!property) { sendError(res, 404, 'Property not found'); return; } - const tenant = new Tenant({ - ...req.body, - organization: property.organization + if (!canManageProperty(req.user, property)) { + sendError(res, 403, 'You do not have permission to add tenants to this property'); + return; + } + + const tenantUser = await User.findById(req.body.user); + if (!tenantUser) { + sendError(res, 404, 'User not found'); + return; + } + + if (tenantUser.role !== ROLES.TENANT) { + sendError(res, 400, 'Only tenant accounts can be assigned to rental units'); + return; + } + + const existingTenant = await Tenant.findOne(req.schema, { + propertyId: req.body.property, + unit: req.body.unit, + status: 'active' }); - await tenant.save(); + if (existingTenant) { + sendError(res, 400, 'Unit is already occupied'); + return; + } + + const tenant = await Tenant.create(req.schema, { + userId: req.body.user, + propertyId: req.body.property, + unit: req.body.unit, + rentAmount: req.body.rentAmount, + dueDate: req.body.dueDate, + status: req.body.status || 'active', + leaseStart: req.body.leaseStart, + leaseEnd: req.body.leaseEnd + }); - if (tenant.user) { - await User.findByIdAndUpdate(tenant.user, { + if (tenant.user_id) { + await User.update(tenant.user_id, { status: 'active', - organization: property.organization + organization_id: req.organizationId + }); + await OrganizationMembership.create({ + userId: tenant.user_id, + organizationId: req.organizationId, + isDefault: false }); } - await Unit.findOneAndUpdate({ - property: property._id, - unitNumber: tenant.unit - }, { - $set: { - organization: property.organization, - occupancyStatus: 'occupied', - tenantAssignment: tenant._id, - isActive: true - } - }, { upsert: true, new: true, setDefaultsOnInsert: true }); + await Unit.upsert(req.schema, { + propertyId: property.id, + unitNumber: tenant.unit, + occupancyStatus: 'occupied', + tenantAssignment: tenant.id, + isActive: true + }); if (!property.units.includes(tenant.unit)) { - property.units.push(tenant.unit); - await property.save(); + const nextUnits = [...property.units, tenant.unit]; + await Property.update(req.schema, property.id, { units: nextUnits }); } await logRequestAudit({ req, - organization: property.organization, + organization: req.organizationId, action: 'Tenant created', entityType: 'tenant', - entityId: tenant._id, + entityId: tenant.id, metadata: { propertyName: property.name, summary: `Unit ${tenant.unit} assigned` @@ -220,20 +355,27 @@ const createTenant = async (req, res) => { }; const getPendingRegistrations = async (req, res) => { - const properties = await Property.find(buildManagedPropertyQuery(req.user)); - const propertyIds = properties.map((property) => property._id); - - const pendingUsers = await User.find({ - status: 'pending', - interestedProperty: { $in: propertyIds } - }).populate('interestedProperty', 'name address'); - - res.json(pendingUsers); + const properties = await Property.find(req.schema, buildManagedPropertyFilter(req.user)); + const propertyIds = properties.map((property) => property.id); + + const pendingUsers = await User.findPendingByPropertyIds(propertyIds, req.schema); + const propertyMap = new Map(properties.map((property) => [property.id, property])); + + res.json(pendingUsers.map((user) => ({ + ...User.sanitize(user), + interestedProperty: propertyMap.get(user.interested_property_id) + ? { + id: propertyMap.get(user.interested_property_id).id, + name: propertyMap.get(user.interested_property_id).name, + address: propertyMap.get(user.interested_property_id).address + } + : null + }))); }; const approveRegistration = async (req, res) => { const { userId } = req.params; - const rentAmount = Number(req.body.rentAmount || 0); + const { rentAmount, depositAmount, dueDate, leaseStart, leaseEnd } = req.body; const user = await User.findById(userId); if (!user) { @@ -241,19 +383,26 @@ const approveRegistration = async (req, res) => { return; } - const property = await Property.findOne({ - _id: user.interestedProperty, - ...buildManagedPropertyQuery(req.user) - }); + if (user.role !== ROLES.TENANT) { + sendError(res, 400, 'Only tenant registrations can be approved here'); + return; + } - if (!property) { + const property = await Property.findById(req.schema, user.interested_property_id); + + if (!property || user.interested_property_schema !== req.schema) { + sendError(res, 403, 'Unauthorized to approve for this property'); + return; + } + + if (!canManageProperty(req.user, property)) { sendError(res, 403, 'Unauthorized to approve for this property'); return; } - const existingTenant = await Tenant.findOne({ - property: property._id, - unit: user.interestedUnit, + const existingTenant = await Tenant.findOne(req.schema, { + propertyId: property.id, + unit: user.interested_unit, status: 'active' }); @@ -262,56 +411,80 @@ const approveRegistration = async (req, res) => { return; } - const tenant = new Tenant({ - organization: property.organization, - user: user._id, - property: property._id, - unit: user.interestedUnit, + const tenant = await Tenant.create(req.schema, { + userId: user.id, + propertyId: property.id, + unit: user.interested_unit, rentAmount, + dueDate: dueDate || 1, + leaseStart: leaseStart || null, + leaseEnd: leaseEnd || null, status: 'active' }); - await tenant.save(); - - user.status = 'active'; - user.organization = property.organization; - await user.save(); - - await Unit.findOneAndUpdate({ - property: property._id, - unitNumber: user.interestedUnit - }, { - $set: { - organization: property.organization, - tenantAssignment: tenant._id, - occupancyStatus: 'occupied', - rentAmount, - isActive: true - } - }, { upsert: true, new: true, setDefaultsOnInsert: true }); + // Create a lease record to track the deposit and terms + const { Lease } = require('../models'); + await Lease.create(req.schema, { + tenantId: user.id, + propertyId: property.id, + unit: user.interested_unit, + depositAmount: depositAmount || 0, + startDate: leaseStart || null, + endDate: leaseEnd || null, + fileName: 'System Generated Entry', + filePath: 'N/A' + }); + + // Generate a new temporary password for the tenant + const tempPassword = crypto.randomBytes(4).toString('hex'); // 8 character hex - if (!property.units.includes(user.interestedUnit)) { - property.units.push(user.interestedUnit); - await property.save(); + await User.update(user.id, { + status: 'active', + organization_id: req.organizationId, + password: tempPassword, + requires_password_change: true + }); + + await OrganizationMembership.create({ + userId: user.id, + organizationId: req.organizationId, + isDefault: true + }); + + // Send the welcome email with the new credentials and financial summary + await sendAccountCreatedEmail(user.email, user.name, tempPassword, ROLES.TENANT); + + await Unit.upsert(req.schema, { + propertyId: property.id, + unitNumber: user.interested_unit, + tenantAssignment: tenant.id, + occupancyStatus: 'occupied', + rentAmount, + isActive: true + }); + + if (!property.units.includes(user.interested_unit)) { + const nextUnits = [...property.units, user.interested_unit]; + await Property.update(req.schema, property.id, { units: nextUnits }); } await createNotification({ - organization: property.organization, - user: user._id, + schema: req.schema, + userId: user.id, title: 'Application approved', - message: `Your application for ${property.name} unit ${user.interestedUnit} has been approved.`, + message: `Your application for ${property.name} unit ${user.interested_unit} has been approved.`, type: 'system_notice' }); await logRequestAudit({ req, - organization: property.organization, + organization: req.organizationId, action: 'Registration approved', entityType: 'tenant', - entityId: tenant._id, + entityId: tenant.id, metadata: { propertyName: property.name, - summary: `${user.name} • Unit ${user.interestedUnit}`, + summary: `${user.name} • Unit ${user.interested_unit}`, rentAmount } }); @@ -328,36 +501,42 @@ const rejectRegistration = async (req, res) => { return; } - const property = await Property.findOne({ - _id: user.interestedProperty, - ...buildManagedPropertyQuery(req.user) - }); + if (user.role !== ROLES.TENANT) { + sendError(res, 400, 'Only tenant registrations can be rejected here'); + return; + } - if (!property) { + const property = await Property.findById(req.schema, user.interested_property_id); + + if (!property || user.interested_property_schema !== req.schema) { + sendError(res, 403, 'Unauthorized to reject for this property'); + return; + } + + if (!canManageProperty(req.user, property)) { sendError(res, 403, 'Unauthorized to reject for this property'); return; } - user.status = 'rejected'; - await user.save(); + await User.update(user.id, { status: 'rejected' }); await createNotification({ - organization: property.organization, - user: user._id, + schema: req.schema, + userId: user.id, title: 'Application rejected', - message: `Your application for ${property.name} unit ${user.interestedUnit} was not approved.`, + message: `Your application for ${property.name} unit ${user.interested_unit} was not approved.`, type: 'system_notice' }); await logRequestAudit({ req, - organization: property.organization, + organization: req.organizationId, action: 'Registration rejected', entityType: 'user', - entityId: user._id, + entityId: user.id, metadata: { propertyName: property.name, - summary: `${user.name} • Unit ${user.interestedUnit}` + summary: `${user.name} • Unit ${user.interested_unit}` } }); @@ -368,6 +547,7 @@ module.exports = { getProperties, createProperty, updateProperty, + deleteProperty, getPropertyTenants, createTenant, getPendingRegistrations, diff --git a/server/controllers/reminderController.js b/server/controllers/reminderController.js index bf5a1f6..5b61672 100644 --- a/server/controllers/reminderController.js +++ b/server/controllers/reminderController.js @@ -1,45 +1,96 @@ -const { Tenant, Property } = require('../models'); +const { Tenant, Property, User, Organization } = require('../models'); const { logRequestAudit } = require('../helpers/audit'); const { sendError } = require('../helpers/apiResponse'); -const { generateReminder } = require('../services/aiService'); +const { processTenantReminder } = require('../services/reminderService'); +const { ROLES } = require('../helpers/rbac'); -const generateTenantReminder = async (req, res) => { +const canManageProperty = (user, property) => Boolean(property) && ( + user.role === ROLES.SUPER_ADMIN + || property.landlord_id === user.id + || property.manager_id === user.id +); + +const toggleAutoReminders = async (req, res) => { + const { enabled } = req.body; + const organization = await Organization.update(req.organizationId, { + auto_reminders_enabled: !!enabled + }); + + res.json({ + message: `Auto-reminders ${enabled ? 'enabled' : 'disabled'}`, + autoRemindersEnabled: organization.auto_reminders_enabled + }); +}; + +const updateReminderSettings = async (req, res) => { + const { beforeDays, afterDays } = req.body; + const organization = await Organization.update(req.organizationId, { + reminder_settings: { + before_days: Number(beforeDays || 3), + after_days: Number(afterDays || 3) + } + }); + + res.json({ + message: 'Reminder settings updated', + reminderSettings: organization.reminder_settings + }); +}; + +const triggerManualReminder = async (req, res) => { const { tenantId } = req.body; - const tenant = await Tenant.findById(tenantId).populate('user'); + const tenant = await Tenant.findById(req.schema, tenantId); if (!tenant) { sendError(res, 404, 'Tenant not found'); return; } - const property = await Property.findById(tenant.property); + const property = await Property.findById(req.schema, tenant.property_id); if (!property) { sendError(res, 404, 'Property not found'); return; } - const reminderText = await generateReminder( - tenant.user.name, - property.address, - tenant.rentAmount, - tenant.dueDate - ); + if (!canManageProperty(req.user, property)) { + sendError(res, 403, 'You do not have permission to generate reminders for this tenant'); + return; + } + + const organization = await Organization.findById(req.organizationId); + const sent = await processTenantReminder(req.schema, tenant, organization); await logRequestAudit({ req, - organization: property.organization || null, - action: 'Reminder generated', + organization: req.organizationId, + action: 'Manual reminder sent', entityType: 'reminder', - entityId: tenant._id, + entityId: tenant.id, metadata: { propertyName: property.name || '', - summary: `${tenant.user.name} • ${property.name || property.address}` + summary: `Manual trigger for unit ${tenant.unit}` } }); - res.json({ reminderText }); + res.json({ message: sent ? 'Reminder sent successfully' : 'Tenant is not currently due for a reminder based on settings, but notification was still processed if forced.' }); +}; + +const triggerAllReminders = async (req, res) => { + const tenants = await Tenant.find(req.schema, { status: 'active' }); + const organization = await Organization.findById(req.organizationId); + + let count = 0; + for (const tenant of tenants) { + const sent = await processTenantReminder(req.schema, tenant, organization); + if (sent) count++; + } + + res.json({ message: `Processed reminders for ${tenants.length} tenants. ${count} notifications were actually sent based on dates.` }); }; module.exports = { - generateTenantReminder + toggleAutoReminders, + updateReminderSettings, + triggerManualReminder, + triggerAllReminders }; diff --git a/server/controllers/repairController.js b/server/controllers/repairController.js index 38e6c22..239395b 100644 --- a/server/controllers/repairController.js +++ b/server/controllers/repairController.js @@ -1,66 +1,98 @@ -const { Property, RepairRequest } = require('../models'); +const { Property, RepairRequest, Tenant, User } = require('../models'); const { logRequestAudit } = require('../helpers/audit'); const { sendError } = require('../helpers/apiResponse'); const { createNotification } = require('../helpers/notifications'); const { ROLES } = require('../helpers/rbac'); -const { toStoredUploadPath } = require('../helpers/upload'); +const { deleteFromCloudinary } = require('../helpers/upload'); const managementRoles = [ ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, - ROLES.CARETAKER, - ROLES.AGENT, ROLES.SUPER_ADMIN ]; +const canManageProperty = (user, property) => Boolean(property) && ( + user.role === ROLES.SUPER_ADMIN + || property.landlord_id === user.id + || property.manager_id === user.id +); + const getRepairRequests = async (req, res) => { let requests; - if ([ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN].includes(req.user.role)) { - const propertyQuery = req.user.role === ROLES.SUPER_ADMIN + if (managementRoles.includes(req.user.role)) { + const filter = req.user.role === ROLES.SUPER_ADMIN ? {} - : { $or: [{ landlord: req.user.id }, { manager: req.user.id }] }; - - const properties = await Property.find(propertyQuery); - const propertyIds = properties.map((property) => property._id); - - requests = await RepairRequest.find({ property: { $in: propertyIds } }) - .populate('tenant', 'name email') - .populate('property', 'name address') - .populate('assignedTo', 'name role'); - } else if ([ROLES.CARETAKER, ROLES.AGENT].includes(req.user.role)) { - requests = await RepairRequest.find({ assignedTo: req.user.id }) - .populate('tenant', 'name email') - .populate('property', 'name address') - .populate('assignedTo', 'name role'); + : { landlordId: req.user.id, managerId: req.user.id }; + const properties = await Property.find(req.schema, filter); + const propertyIds = properties.map((property) => property.id); + requests = await RepairRequest.findByPropertyIds(req.schema, propertyIds); } else { - requests = await RepairRequest.find({ tenant: req.user.id }) - .populate('property', 'name address') - .populate('assignedTo', 'name role'); + requests = await RepairRequest.findByTenant(req.schema, req.user.id); } - res.json(requests); + const propertyIds = [...new Set(requests.map((request) => request.property_id))]; + const properties = await Promise.all(propertyIds.map((id) => Property.findById(req.schema, id))); + const propertyMap = new Map(properties.filter(Boolean).map((property) => [property.id, property])); + + const userIds = [...new Set(requests.flatMap((request) => [ + request.tenant_id, + request.assigned_to + ]).filter(Boolean))]; + const users = await User.findByIds(userIds); + const userMap = new Map(users.map((user) => [user.id, user])); + + res.json(requests.map((request) => ({ + ...request, + tenant: userMap.get(request.tenant_id) + ? { id: userMap.get(request.tenant_id).id, name: userMap.get(request.tenant_id).name, email: userMap.get(request.tenant_id).email } + : null, + property: propertyMap.get(request.property_id) + ? { id: propertyMap.get(request.property_id).id, name: propertyMap.get(request.property_id).name, address: propertyMap.get(request.property_id).address } + : null, + assignedTo: userMap.get(request.assigned_to) + ? { id: userMap.get(request.assigned_to).id, name: userMap.get(request.assigned_to).name, role: userMap.get(request.assigned_to).role } + : null + }))); }; const createRepairRequest = async (req, res) => { - const property = await Property.findById(req.body.propertyId).select('organization landlord'); + const property = await Property.findById(req.schema, req.body.propertyId); + if (!property) { + sendError(res, 404, 'Property not found'); + return; + } + + if (req.user.role !== ROLES.TENANT) { + sendError(res, 403, 'Only tenants can create repair requests'); + return; + } - const repairRequest = new RepairRequest({ - organization: property?.organization, - tenant: req.user.id, - property: req.body.propertyId, + const tenant = await Tenant.findOne(req.schema, { + userId: req.user.id, + propertyId: req.body.propertyId, + unit: req.body.unit, + status: 'active' + }); + + if (!tenant) { + sendError(res, 403, 'Only the assigned tenant can create a repair request for this unit'); + return; + } + + const repairRequest = await RepairRequest.create(req.schema, { + tenantId: req.user.id, + propertyId: req.body.propertyId, unit: req.body.unit, category: req.body.category || 'general', description: req.body.description, - imagePath: req.file ? toStoredUploadPath(req.file.path) : null + imagePath: req.file ? req.file.path : null // req.file.path is the Cloudinary URL }); - await repairRequest.save(); - - if (property?.landlord) { + if (property?.landlord_id) { await createNotification({ - organization: property.organization, - user: property.landlord, + schema: req.schema, + userId: property.landlord_id, title: 'New maintenance request', message: `A new maintenance request was submitted for unit ${req.body.unit}.`, type: 'maintenance_update' @@ -69,10 +101,10 @@ const createRepairRequest = async (req, res) => { await logRequestAudit({ req, - organization: property?.organization || null, + organization: req.organizationId, action: 'Repair request created', entityType: 'repair_request', - entityId: repairRequest._id, + entityId: repairRequest.id, metadata: { propertyName: property?.name || '', summary: `Unit ${req.body.unit} • ${req.body.category || 'general'}` @@ -88,24 +120,40 @@ const updateRepairRequest = async (req, res) => { return; } - const repairRequest = await RepairRequest.findByIdAndUpdate(req.params.id, { + const existingRequest = await RepairRequest.findById(req.schema, req.params.id); + if (!existingRequest) { + sendError(res, 404, 'Repair request not found'); + return; + } + + const property = await Property.findById(req.schema, existingRequest.property_id); + if (!property) { + sendError(res, 404, 'Property not found'); + return; + } + + if (!canManageProperty(req.user, property)) { + sendError(res, 403, 'You do not have permission to update this repair request'); + return; + } + + const updates = { status: req.body.status, - landlordResponse: req.body.landlordResponse, - technicianDetails: req.body.technicianDetails, - assignedTo: req.body.assignedTo, - cost: req.body.cost - }, { new: true }) - .populate('tenant', 'name email') - .populate('property', 'name address'); + technician_details: req.body.technicianDetails, + cost: req.body.cost, + landlord_response: req.body.landlordResponse, + assigned_to: req.body.assignedTo + }; + const repairRequest = await RepairRequest.update(req.schema, req.params.id, updates); if (!repairRequest) { - sendError(res, 404, 'Repair request not found'); + sendError(res, 400, 'No repair request updates provided'); return; } await createNotification({ - organization: repairRequest.organization, - user: repairRequest.tenant?._id || repairRequest.tenant, + schema: req.schema, + userId: repairRequest.tenant_id, title: 'Maintenance request updated', message: `Your maintenance request for unit ${repairRequest.unit} is now ${repairRequest.status}.`, type: 'maintenance_update' @@ -113,12 +161,12 @@ const updateRepairRequest = async (req, res) => { await logRequestAudit({ req, - organization: repairRequest.organization, + organization: req.organizationId, action: 'Repair request updated', entityType: 'repair_request', - entityId: repairRequest._id, + entityId: repairRequest.id, metadata: { - propertyName: repairRequest.property?.name || '', + propertyName: property?.name || '', summary: `Unit ${repairRequest.unit} • ${repairRequest.status}`, status: repairRequest.status } @@ -127,8 +175,40 @@ const updateRepairRequest = async (req, res) => { res.json(repairRequest); }; +const deleteRepairRequest = async (req, res) => { + const existingRequest = await RepairRequest.findById(req.schema, req.params.id); + if (!existingRequest) { + sendError(res, 404, 'Repair request not found'); + return; + } + + const property = await Property.findById(req.schema, existingRequest.property_id); + if (!canManageProperty(req.user, property) && existingRequest.tenant_id !== req.user.id) { + sendError(res, 403, 'Unauthorized to delete this request'); + return; + } + + // Delete from Cloudinary if image exists + if (existingRequest.image_path) { + await deleteFromCloudinary(existingRequest.image_path); + } + + await RepairRequest.delete(req.schema, req.params.id); + + await logRequestAudit({ + req, + organization: req.organizationId, + action: 'Repair request deleted', + entityType: 'repair_request', + entityId: req.params.id + }); + + res.json({ message: 'Repair request deleted successfully' }); +}; + module.exports = { getRepairRequests, createRepairRequest, - updateRepairRequest + updateRepairRequest, + deleteRepairRequest }; diff --git a/server/controllers/suggestionController.js b/server/controllers/suggestionController.js index 3c8269a..6263d7e 100644 --- a/server/controllers/suggestionController.js +++ b/server/controllers/suggestionController.js @@ -3,42 +3,37 @@ const { logRequestAudit, truncateAuditText } = require('../helpers/audit'); const { sendError } = require('../helpers/apiResponse'); const { ROLES } = require('../helpers/rbac'); -const buildManagedPropertyQuery = (user) => { +const buildManagedPropertyFilter = (user) => { if (user.role === ROLES.SUPER_ADMIN) { return {}; } return { - $or: [ - { landlord: user.id }, - { manager: user.id } - ] + landlordId: user.id, + managerId: user.id }; }; const createSuggestion = async (req, res) => { - const tenant = await Tenant.findOne({ user: req.user.id, status: 'active' }); + const tenant = await Tenant.findOne(req.schema, { userId: req.user.id, status: 'active' }); if (!tenant) { sendError(res, 403, 'Only active tenants can send suggestions'); return; } - const suggestion = new Suggestion({ - organization: tenant.organization, - property: tenant.property, + const suggestion = await Suggestion.create(req.schema, { + propertyId: tenant.property_id, content: req.body.content }); - await suggestion.save(); - await logRequestAudit({ req, - organization: tenant.organization, + organization: req.organizationId, action: 'Suggestion submitted', entityType: 'suggestion', - entityId: suggestion._id, + entityId: suggestion.id, metadata: { - propertyId: tenant.property, + propertyId: tenant.property_id, summary: truncateAuditText(req.body.content, 72) } }); @@ -47,14 +42,22 @@ const createSuggestion = async (req, res) => { }; const getSuggestions = async (req, res) => { - const properties = await Property.find(buildManagedPropertyQuery(req.user)); - const propertyIds = properties.map((property) => property._id); + const properties = await Property.find(req.schema, buildManagedPropertyFilter(req.user)); + const propertyIds = properties.map((property) => property.id); + const propertyMap = new Map(properties.map((property) => [property.id, property])); - const suggestions = await Suggestion.find({ - property: { $in: propertyIds } - }).populate('property', 'name address').sort({ createdAt: -1 }); + const suggestions = await Suggestion.findByPropertyIds(req.schema, propertyIds); - res.json(suggestions); + res.json(suggestions.map((suggestion) => ({ + ...suggestion, + property: propertyMap.get(suggestion.property_id) + ? { + id: propertyMap.get(suggestion.property_id).id, + name: propertyMap.get(suggestion.property_id).name, + address: propertyMap.get(suggestion.property_id).address + } + : null + }))); }; module.exports = { diff --git a/server/controllers/unitController.js b/server/controllers/unitController.js index b821228..022abd5 100644 --- a/server/controllers/unitController.js +++ b/server/controllers/unitController.js @@ -1,30 +1,49 @@ -const { Property, Unit } = require('../models'); +const { Property, Tenant, Unit } = require('../models'); const { logRequestAudit } = require('../helpers/audit'); const { sendError } = require('../helpers/apiResponse'); const { ROLES } = require('../helpers/rbac'); const manageRoles = [ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN]; -const buildManagedPropertyQuery = (user) => { - if (user.role === ROLES.SUPER_ADMIN) { - return {}; +const canManageProperty = (user, property) => Boolean(property) && ( + user.role === ROLES.SUPER_ADMIN + || property.landlord_id === user.id + || property.manager_id === user.id +); + +const mapUnit = (unit) => ({ + ...unit, + property: unit.property_id, + unitNumber: unit.unit_number, + rentAmount: unit.rent_amount, + occupancyStatus: unit.occupancy_status, + tenantAssignment: unit.tenant_assignment, + isActive: unit.is_active, + meterReadings: unit.meter_readings || {} +}); + +const getUnitsByProperty = async (req, res) => { + const property = await Property.findById(req.schema, req.params.propertyId); + if (!property) { + sendError(res, 404, 'Property not found'); + return; } - return { - $or: [ - { landlord: user.id }, - { manager: user.id } - ] - }; -}; + if (!canManageProperty(req.user, property)) { + const tenant = await Tenant.findOne(req.schema, { + userId: req.user.id, + propertyId: property.id, + status: 'active' + }); -const getUnitsByProperty = async (req, res) => { - const units = await Unit.find({ - property: req.params.propertyId, - isActive: { $ne: false } - }).sort({ unitNumber: 1 }); + if (!tenant) { + sendError(res, 403, 'You do not have permission to view units for this property'); + return; + } + } - res.json(units); + const units = await Unit.findByProperty(req.schema, req.params.propertyId); + res.json(units.map(mapUnit)); }; const createUnit = async (req, res) => { @@ -33,57 +52,51 @@ const createUnit = async (req, res) => { return; } - const property = await Property.findOne({ - _id: req.body.propertyId, - ...buildManagedPropertyQuery(req.user) - }); + const property = await Property.findById(req.schema, req.body.propertyId); if (!property) { sendError(res, 404, 'Property not found'); return; } + if (!canManageProperty(req.user, property)) { + sendError(res, 403, 'You do not have permission to update this property'); + return; + } + const unitNumber = String(req.body.unitNumber || '').trim(); if (!unitNumber) { sendError(res, 400, 'Unit number is required'); return; } - const unit = await Unit.findOneAndUpdate({ - property: property._id, - unitNumber - }, { - organization: property.organization, - property: property._id, + const unit = await Unit.upsert(req.schema, { + propertyId: property.id, unitNumber, rentAmount: Number(req.body.rentAmount || 0), occupancyStatus: req.body.occupancyStatus || 'vacant', meterReadings: req.body.meterReadings || {}, isActive: true - }, { - new: true, - upsert: true, - setDefaultsOnInsert: true }); if (!property.units.includes(unitNumber)) { - property.units.push(unitNumber); - await property.save(); + const nextUnits = [...property.units, unitNumber]; + await Property.update(req.schema, property.id, { units: nextUnits }); } await logRequestAudit({ req, - organization: property.organization, + organization: req.organizationId, action: 'Unit created', entityType: 'unit', - entityId: unit._id, + entityId: unit.id, metadata: { propertyName: property.name, - summary: `${property.name} • Unit ${unit.unitNumber}` + summary: `${property.name} • Unit ${unit.unit_number}` } }); - res.status(201).json(unit); + res.status(201).json(mapUnit(unit)); }; const updateUnit = async (req, res) => { @@ -92,57 +105,59 @@ const updateUnit = async (req, res) => { return; } - const unit = await Unit.findById(req.params.id).populate('property'); + const unit = await Unit.findById(req.schema, req.params.id); if (!unit) { sendError(res, 404, 'Unit not found'); return; } - const managedProperty = await Property.findOne({ - _id: unit.property._id, - ...buildManagedPropertyQuery(req.user) - }); - + const managedProperty = await Property.findById(req.schema, unit.property_id); if (!managedProperty) { + sendError(res, 404, 'Property not found'); + return; + } + + if (!canManageProperty(req.user, managedProperty)) { sendError(res, 403, 'You do not have permission to update this unit'); return; } - const previousUnitNumber = unit.unitNumber; + const previousUnitNumber = unit.unit_number; const nextUnitNumber = req.body.unitNumber ? String(req.body.unitNumber).trim() : previousUnitNumber; - unit.unitNumber = nextUnitNumber; - unit.rentAmount = req.body.rentAmount ?? unit.rentAmount; - unit.occupancyStatus = req.body.occupancyStatus || unit.occupancyStatus; - unit.meterReadings = req.body.meterReadings || unit.meterReadings; - unit.isActive = req.body.isActive ?? unit.isActive; - await unit.save(); + const updatedUnit = await Unit.update(req.schema, unit.id, { + unit_number: nextUnitNumber, + rent_amount: req.body.rentAmount ?? unit.rent_amount, + occupancy_status: req.body.occupancyStatus || unit.occupancy_status, + meter_readings: req.body.meterReadings || unit.meter_readings, + is_active: req.body.isActive ?? unit.is_active + }); - managedProperty.units = [...new Set( + const nextUnits = [...new Set( managedProperty.units .map((propertyUnit) => propertyUnit === previousUnitNumber ? nextUnitNumber : propertyUnit) .filter(Boolean) )]; - if (!managedProperty.units.includes(nextUnitNumber)) { - managedProperty.units.push(nextUnitNumber); + if (!nextUnits.includes(nextUnitNumber)) { + nextUnits.push(nextUnitNumber); } - await managedProperty.save(); + await Property.update(req.schema, managedProperty.id, { units: nextUnits }); await logRequestAudit({ req, - organization: managedProperty.organization, + organization: req.organizationId, action: 'Unit updated', entityType: 'unit', - entityId: unit._id, + entityId: updatedUnit.id, metadata: { propertyName: managedProperty.name, - summary: `${managedProperty.name} • Unit ${unit.unitNumber}` + summary: `${managedProperty.name} • Unit ${updatedUnit.unit_number}` } }); - res.json(unit); + res.json(mapUnit(updatedUnit)); }; module.exports = { diff --git a/server/controllers/webhookController.js b/server/controllers/webhookController.js new file mode 100644 index 0000000..9605913 --- /dev/null +++ b/server/controllers/webhookController.js @@ -0,0 +1,62 @@ +const crypto = require('crypto'); +const { Organization, Subscription } = require('../models'); +const { logAudit } = require('../helpers/audit'); + +const handlePaystackWebhook = async (req, res) => { + // 1. Verify the Paystack Signature + const hash = crypto.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY) + .update(JSON.stringify(req.body)) + .digest('hex'); + + if (hash !== req.headers['x-paystack-signature']) { + return res.status(401).send('Invalid signature'); + } + + const event = req.body; + + // 2. Listen for successful charges + if (event.event === 'charge.success') { + const { organizationId, type } = event.data.metadata; + + if (type === 'subscription_payment' && organizationId) { + console.log(`Reactivating organization: ${organizationId}`); + + // 3. Reactivate the Organization + await Organization.update(organizationId, { status: 'active' }); + + // 4. Update Subscription Dates + const sub = await Subscription.findByOrganization(organizationId); + const org = await Organization.findById(organizationId); + + if (sub && org) { + const nextDate = new Date(); + nextDate.setMonth(nextDate.getMonth() + (org.billing_cycle_months || 1)); + + await Subscription.update(sub.id, { + status: 'active', + last_billed_at: new Date(), + next_billing_date: nextDate + }); + + await logAudit({ + organization: organizationId, + action: 'Subscription renewed', + entityType: 'subscription', + entityId: sub.id, + metadata: { + amount: event.data.amount / 100, + reference: event.data.reference, + summary: 'Automated reactivation via Paystack Webhook' + } + }); + } + } + } + + // Acknowledge receipt of the webhook + res.status(200).send('OK'); +}; + +module.exports = { + handlePaystackWebhook +}; diff --git a/server/database/index.js b/server/database/index.js new file mode 100644 index 0000000..336ad40 --- /dev/null +++ b/server/database/index.js @@ -0,0 +1,48 @@ +const fs = require('fs'); +const path = require('path'); +const { pool, quoteIdentifier } = require('../config/database'); + +const readSqlFiles = (dir) => fs.readdirSync(dir) + .filter((file) => file.endsWith('.sql')) + .sort() + .map((file) => path.join(dir, file)); + +const runSqlFiles = async (client, files) => { + for (const file of files) { + const sql = fs.readFileSync(file, 'utf8'); + if (sql.trim()) { + await client.query(sql); + } + } +}; + +const runPublicMigrations = async () => { + const dir = path.join(__dirname, 'migrations', 'public'); + const client = await pool.connect(); + try { + await client.query('SET search_path TO public'); + await runSqlFiles(client, readSqlFiles(dir)); + } finally { + await client.query('RESET search_path').catch(() => {}); + client.release(); + } +}; + +const runTenantMigrations = async (schemaName) => { + const dir = path.join(__dirname, 'migrations', 'tenant'); + const client = await pool.connect(); + const quotedSchema = quoteIdentifier(schemaName); + try { + await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema}`); + await client.query(`SET search_path TO ${quotedSchema}, public`); + await runSqlFiles(client, readSqlFiles(dir)); + } finally { + await client.query('RESET search_path').catch(() => {}); + client.release(); + } +}; + +module.exports = { + runPublicMigrations, + runTenantMigrations +}; diff --git a/server/database/migrations/public/001_public_schema.sql b/server/database/migrations/public/001_public_schema.sql new file mode 100644 index 0000000..b0b6cb7 --- /dev/null +++ b/server/database/migrations/public/001_public_schema.sql @@ -0,0 +1,90 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TABLE IF NOT EXISTS public.organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + owner_id UUID, + status TEXT NOT NULL DEFAULT 'trial' + CHECK (status IN ('trial', 'active', 'suspended', 'inactive')), + subscription_plan TEXT NOT NULL DEFAULT 'basic' + CHECK (subscription_plan IN ('basic', 'pro', 'enterprise')), + billing_cycle TEXT NOT NULL DEFAULT 'monthly' + CHECK (billing_cycle IN ('monthly', 'yearly')), + settings JSONB NOT NULL DEFAULT '{}'::jsonb, + schema_name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID REFERENCES public.organizations(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + phone_number TEXT, + role TEXT NOT NULL DEFAULT 'tenant' + CHECK (role IN ('tenant', 'landlord', 'property_manager', 'super_admin')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'active', 'rejected', 'suspended')), + interested_property_id UUID, + interested_property_schema TEXT, + interested_unit TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.organization_memberships ( + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, organization_id) +); + +CREATE TABLE IF NOT EXISTS public.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE, + plan TEXT NOT NULL DEFAULT 'basic' + CHECK (plan IN ('basic', 'pro', 'enterprise')), + billing_cycle TEXT NOT NULL DEFAULT 'monthly' + CHECK (billing_cycle IN ('monthly', 'yearly')), + status TEXT NOT NULL DEFAULT 'trial' + CHECK (status IN ('trial', 'active', 'past_due', 'cancelled', 'suspended')), + provider TEXT DEFAULT 'manual', + provider_subscription_id TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ends_at TIMESTAMPTZ, + trial_ends_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID REFERENCES public.organizations(id) ON DELETE SET NULL, + actor_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + entity_type TEXT, + entity_id UUID, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'organizations_owner_id_fkey' + ) THEN + ALTER TABLE public.organizations + ADD CONSTRAINT organizations_owner_id_fkey + FOREIGN KEY (owner_id) REFERENCES public.users(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_users_org ON public.users(organization_id); +CREATE INDEX IF NOT EXISTS idx_memberships_org ON public.organization_memberships(organization_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_org ON public.audit_logs(organization_id); diff --git a/server/database/migrations/public/002_add_requires_password_change.sql b/server/database/migrations/public/002_add_requires_password_change.sql new file mode 100644 index 0000000..7dfeb37 --- /dev/null +++ b/server/database/migrations/public/002_add_requires_password_change.sql @@ -0,0 +1,12 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'users' + AND TABLE_SCHEMA = 'public' + AND COLUMN_NAME = 'requires_password_change' + ) THEN + ALTER TABLE public.users ADD COLUMN requires_password_change BOOLEAN NOT NULL DEFAULT FALSE; + END IF; +END $$; diff --git a/server/database/migrations/public/003_add_org_reminder_settings.sql b/server/database/migrations/public/003_add_org_reminder_settings.sql new file mode 100644 index 0000000..694cd80 --- /dev/null +++ b/server/database/migrations/public/003_add_org_reminder_settings.sql @@ -0,0 +1,13 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'organizations' + AND TABLE_SCHEMA = 'public' + AND COLUMN_NAME = 'auto_reminders_enabled' + ) THEN + ALTER TABLE public.organizations ADD COLUMN auto_reminders_enabled BOOLEAN NOT NULL DEFAULT FALSE; + ALTER TABLE public.organizations ADD COLUMN reminder_settings JSONB NOT NULL DEFAULT '{"before_days": 3, "after_days": 3}'::jsonb; + END IF; +END $$; diff --git a/server/database/migrations/public/004_add_global_chat_setting.sql b/server/database/migrations/public/004_add_global_chat_setting.sql new file mode 100644 index 0000000..45d60bf --- /dev/null +++ b/server/database/migrations/public/004_add_global_chat_setting.sql @@ -0,0 +1,12 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'organizations' + AND TABLE_SCHEMA = 'public' + AND COLUMN_NAME = 'global_chat_enabled' + ) THEN + ALTER TABLE public.organizations ADD COLUMN global_chat_enabled BOOLEAN NOT NULL DEFAULT FALSE; + END IF; +END $$; diff --git a/server/database/migrations/public/005_add_org_payment_credentials.sql b/server/database/migrations/public/005_add_org_payment_credentials.sql new file mode 100644 index 0000000..b399f19 --- /dev/null +++ b/server/database/migrations/public/005_add_org_payment_credentials.sql @@ -0,0 +1,14 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'organizations' AND COLUMN_NAME = 'mpesa_shortcode' + ) THEN + ALTER TABLE public.organizations ADD COLUMN mpesa_shortcode TEXT; + ALTER TABLE public.organizations ADD COLUMN mpesa_consumer_key TEXT; + ALTER TABLE public.organizations ADD COLUMN mpesa_consumer_secret TEXT; + ALTER TABLE public.organizations ADD COLUMN mpesa_passkey TEXT; + ALTER TABLE public.organizations ADD COLUMN bank_details JSONB DEFAULT '{}'::jsonb; + ALTER TABLE public.organizations ADD COLUMN payment_methods TEXT[] DEFAULT '{"mpesa"}'::text[]; + END IF; +END $$; diff --git a/server/database/migrations/public/006_add_subscription_billing_configs.sql b/server/database/migrations/public/006_add_subscription_billing_configs.sql new file mode 100644 index 0000000..e8b152f --- /dev/null +++ b/server/database/migrations/public/006_add_subscription_billing_configs.sql @@ -0,0 +1,19 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'organizations' AND COLUMN_NAME = 'price_per_unit' + ) THEN + ALTER TABLE public.organizations ADD COLUMN price_per_unit NUMERIC(12, 2) DEFAULT 500.00; + ALTER TABLE public.organizations ADD COLUMN billing_cycle_months INT DEFAULT 1; -- 1 month, 3 months, 12 months etc. + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'subscriptions' AND COLUMN_NAME = 'next_billing_date' + ) THEN + ALTER TABLE public.subscriptions ADD COLUMN next_billing_date TIMESTAMPTZ; + ALTER TABLE public.subscriptions ADD COLUMN last_billed_at TIMESTAMPTZ; + ALTER TABLE public.subscriptions ADD COLUMN billable_units_at_last_bill INT DEFAULT 0; + END IF; +END $$; diff --git a/server/database/migrations/tenant/001_tenant_schema.sql b/server/database/migrations/tenant/001_tenant_schema.sql new file mode 100644 index 0000000..2aa1914 --- /dev/null +++ b/server/database/migrations/tenant/001_tenant_schema.sql @@ -0,0 +1,159 @@ +CREATE TABLE IF NOT EXISTS properties ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + address TEXT NOT NULL, + description TEXT, + type TEXT DEFAULT 'apartment', + landlord_id UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT, + manager_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + units TEXT[] NOT NULL DEFAULT '{}'::text[], + images TEXT[] NOT NULL DEFAULT '{}'::text[], + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + unit TEXT NOT NULL, + rent_amount NUMERIC(12, 2) NOT NULL, + due_date INT NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'defaulted', 'moved_out', 'pending')), + start_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + lease_start TIMESTAMPTZ, + lease_end TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS units ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + unit_number TEXT NOT NULL, + rent_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, + occupancy_status TEXT NOT NULL DEFAULT 'vacant' + CHECK (occupancy_status IN ('vacant', 'occupied', 'reserved', 'maintenance')), + tenant_assignment UUID REFERENCES tenants(id) ON DELETE SET NULL, + meter_readings JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (property_id, unit_number) +); + +CREATE TABLE IF NOT EXISTS leases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + unit TEXT NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + start_date TIMESTAMPTZ, + end_date TIMESTAMPTZ, + deposit_amount NUMERIC(12, 2) NOT NULL DEFAULT 0, + penalty_terms TEXT, + signed_at TIMESTAMPTZ, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + property_id UUID REFERENCES properties(id) ON DELETE SET NULL, + unit TEXT, + amount NUMERIC(12, 2) NOT NULL, + utilities NUMERIC(12, 2) NOT NULL DEFAULT 0, + penalties NUMERIC(12, 2) NOT NULL DEFAULT 0, + total_amount NUMERIC(12, 2) NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'sent', 'paid', 'overdue', 'cancelled')), + due_date TIMESTAMPTZ NOT NULL, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + line_items JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS mpesa_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + merchant_request_id TEXT, + checkout_request_id TEXT UNIQUE, + phone_number TEXT, + amount NUMERIC(12, 2), + mpesa_receipt_number TEXT, + transaction_date TIMESTAMPTZ, + result_code INT, + result_desc TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'completed', 'failed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL, + amount NUMERIC(12, 2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'KSh', + method TEXT NOT NULL DEFAULT 'mpesa' + CHECK (method IN ('mpesa', 'bank_transfer', 'card', 'cash', 'other')), + reference TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('paid', 'pending', 'overdue', 'failed')), + payment_date TIMESTAMPTZ, + due_date TIMESTAMPTZ NOT NULL, + mpesa_transaction_id UUID REFERENCES mpesa_transactions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sender_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + recipient_id UUID REFERENCES public.users(id) ON DELETE SET NULL, + content TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + message TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'system_notice' + CHECK (type IN ('rent_due', 'payment_confirmation', 'maintenance_update', 'lease_expiry', 'system_notice')), + channel TEXT NOT NULL DEFAULT 'in_app' + CHECK (channel IN ('sms', 'email', 'in_app')), + is_read BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS repair_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + unit TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'general' + CHECK (category IN ('plumbing', 'electricity', 'security', 'general')), + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in-progress', 'resolved')), + assigned_to UUID REFERENCES public.users(id) ON DELETE SET NULL, + landlord_response TEXT, + technician_details TEXT, + image_path TEXT, + cost NUMERIC(12, 2) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS suggestions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/server/database/migrations/tenant/002_add_property_id_to_messages.sql b/server/database/migrations/tenant/002_add_property_id_to_messages.sql new file mode 100644 index 0000000..d7d0dbe --- /dev/null +++ b/server/database/migrations/tenant/002_add_property_id_to_messages.sql @@ -0,0 +1,12 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = 'messages' + AND COLUMN_NAME = 'property_id' + AND TABLE_SCHEMA = current_schema() + ) THEN + ALTER TABLE messages ADD COLUMN property_id UUID REFERENCES properties(id) ON DELETE CASCADE; + END IF; +END $$; diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..da16c01 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + db: + image: postgres:15-alpine + container_name: apex-db + restart: always + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-apex_rentals} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + backend: + build: . + container_name: apex-backend + restart: always + ports: + - "5000:5000" + environment: + - NODE_ENV=production + - PORT=5000 + - DB_HOST=db + - DB_USER=${DB_USER:-postgres} + - DB_PASSWORD=${DB_PASSWORD:-postgres} + - DB_NAME=${DB_NAME:-apex_rentals} + - JWT_SECRET=${JWT_SECRET:-your_super_secret_jwt_key} + - GEMINI_API_KEY=${GEMINI_API_KEY} + - CLOUDINARY_CLOUD_NAME=${CLOUDINARY_CLOUD_NAME} + - CLOUDINARY_API_KEY=${CLOUDINARY_API_KEY} + - CLOUDINARY_API_SECRET=${CLOUDINARY_API_SECRET} + - MPESA_CONSUMER_KEY=${MPESA_CONSUMER_KEY} + - MPESA_CONSUMER_SECRET=${MPESA_CONSUMER_SECRET} + - MPESA_SHORTCODE=${MPESA_SHORTCODE} + - MPESA_PASSKEY=${MPESA_PASSKEY} + - MPESA_CALLBACK_URL=${MPESA_CALLBACK_URL} + - PAYSTACK_SECRET_KEY=${PAYSTACK_SECRET_KEY} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} + depends_on: + - db + +volumes: + pgdata: diff --git a/server/docs/API_DOCUMENTATION.md b/server/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..459a59e --- /dev/null +++ b/server/docs/API_DOCUMENTATION.md @@ -0,0 +1,398 @@ +# Apex Rentals API Documentation + +Base URL: `http://localhost:5000` + +API base URL: `http://localhost:5000/api` + +## Multi-Tenancy + +Manager registration creates an organization plus a dedicated Postgres tenant schema. Login returns a JWT tied to one `organizationId`. Tenant-scoped routes pass through `tenantResolver`, which verifies organization membership, active/trial organization status, and tenant schema availability. + +Platform admin routes under `/api/admin/*` are global and require `super_admin`. They do not use tenant schema resolution. Cross-organization access is expected to fail; the endpoint suite verifies that one landlord cannot log into another organization's context or read another organization's property data. + +Run the verified endpoint suite: + +```bash +npm run test:endpoints +``` + +Latest local Docker result: `77 passed, 0 failed, 3 skipped`. + +Skipped checks require real M-Pesa, Cloudinary, or Paystack credentials and `RUN_PROVIDER_TESTS=true`. + +## Headers + +JSON routes: + +```http +Content-Type: application/json +Authorization: Bearer +``` + +File routes use `multipart/form-data`. + +## System + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| GET | `/` | None | None | +| GET | `/health` | None | None | + +## Auth + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| GET | `/api/auth/properties/available` | Optional | None | +| GET | `/api/auth/me` | Required | None | +| POST | `/api/auth/register` | None | See examples below | +| POST | `/api/auth/inquiry` | None | See example below | +| POST | `/api/auth/login` | None | `{ "email": "landlord@example.com", "password": "Password123!", "organizationId": "organization-uuid" }` | +| POST | `/api/auth/change-password` | Required | `{ "currentPassword": "TempPass123!", "newPassword": "NewPass123!" }` | +| GET | `/api/auth/staff` | Landlord or super admin | None | +| POST | `/api/auth/staff` | Landlord or super admin | `{ "name": "Manager", "email": "manager@example.com", "password": "StaffPass123!", "role": "property_manager", "phoneNumber": "+254700000004" }` | + +Landlord registration: + +```json +{ + "name": "Main Landlord", + "email": "landlord@example.com", + "password": "Password123!", + "role": "landlord", + "phoneNumber": "+254700000001" +} +``` + +Tenant registration: + +```json +{ + "name": "Tenant User", + "email": "tenant@example.com", + "password": "Password123!", + "role": "tenant", + "phoneNumber": "+254700000002", + "interestedProperty": "property-uuid", + "interestedUnit": "A1", + "organizationId": "organization-uuid" +} +``` + +Guest inquiry: + +```json +{ + "name": "Inquiry User", + "email": "inquiry@example.com", + "phoneNumber": "+254700000003", + "interestedProperty": "property-uuid", + "interestedUnit": "B1", + "organizationId": "organization-uuid" +} +``` + +## Admin + +All admin routes require `super_admin`. + +| Method | Route | Payload | +| --- | --- | --- | +| GET | `/api/admin/summary` | None | +| GET | `/api/admin/organizations` | None | +| PUT | `/api/admin/organizations/:id/billing` | `{ "pricePerUnit": 500, "billingCycleMonths": 1, "status": "active" }` | +| GET | `/api/admin/logs?limit=60` | None | +| GET | `/api/admin/users/pending` | None | +| POST | `/api/admin/users/:userId/approve` | None | + +## Billing + +Billing routes require auth and tenant schema resolution. + +| Method | Route | Payload | +| --- | --- | --- | +| GET | `/api/billing/my-billing` | None | +| POST | `/api/billing/pay-subscription` | None | + +`pay-subscription` returns a Paystack `paymentUrl` when there is a balance, or `{ "message": "No balance due at this time." }` when the computed balance is zero. + +## Properties And Tenants + +Property routes require auth and tenant schema resolution. + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| GET | `/api/properties` | Required | None | +| POST | `/api/properties` | Landlord, property manager, super admin | See example below | +| PUT | `/api/properties/:id` | Manager for property, owner landlord, super admin | See example below | +| DELETE | `/api/properties/:id` | Manager for property, owner landlord, super admin | None | +| GET | `/api/properties/:id/tenants` | Manager for property, owner landlord, super admin | None | +| POST | `/api/tenants` | Landlord, property manager, super admin | See example below | +| GET | `/api/pending-registrations` | Landlord, property manager, super admin | None | +| POST | `/api/approve-registration/:userId` | Manager for requested property, owner landlord, super admin | See example below | +| POST | `/api/reject-registration/:userId` | Manager for requested property, owner landlord, super admin | None | + +Create property: + +```json +{ + "name": "Grand Residency", + "address": "Nairobi CBD", + "description": "Apartment block near CBD", + "type": "apartment", + "units": ["A1", "A2", "B1"], + "landlord": "optional-landlord-user-uuid", + "manager": "optional-manager-user-uuid" +} +``` + +Update property: + +```json +{ + "name": "Grand Residency Updated", + "address": "Nairobi West", + "description": "", + "type": "apartment", + "units": ["A1", "A2", "B1", "C1"], + "manager": "optional-manager-user-uuid" +} +``` + +Create tenant profile: + +```json +{ + "user": "tenant-user-uuid", + "property": "property-uuid", + "unit": "A1", + "rentAmount": 12000, + "dueDate": 1, + "status": "active", + "leaseStart": "2026-01-01", + "leaseEnd": "2026-12-31" +} +``` + +Approve tenant registration: + +```json +{ + "rentAmount": 13000, + "depositAmount": 13000, + "dueDate": 1, + "leaseStart": "2026-01-01", + "leaseEnd": "2026-12-31" +} +``` + +## Units + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| GET | `/api/properties/:propertyId/units` | Manager for property, assigned tenant, super admin | None | +| POST | `/api/units` | Landlord, property manager, super admin | See example below | +| PUT | `/api/units/:id` | Landlord, property manager, super admin | See example below | + +Create unit: + +```json +{ + "propertyId": "property-uuid", + "unitNumber": "D1", + "rentAmount": 15000, + "occupancyStatus": "vacant", + "meterReadings": { + "water": 10, + "electricity": 50 + } +} +``` + +Update unit: + +```json +{ + "unitNumber": "D1", + "rentAmount": 15500, + "occupancyStatus": "reserved", + "meterReadings": { + "water": 12, + "electricity": 55 + }, + "isActive": true +} +``` + +`occupancyStatus`: `vacant`, `occupied`, `reserved`, `maintenance`. + +## Messages + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| GET | `/api/messages?propertyId=property-uuid` | Required | None | +| POST | `/api/messages` | Required | `{ "content": "Welcome to the portal.", "propertyId": "optional-property-uuid" }` | +| POST | `/api/messages/toggle-global` | Landlord, property manager, super admin | `{ "enabled": true }` | + +Tenants may omit `propertyId`; they default to their assigned property chat. Global tenant chat requires organization-wide chat to be enabled. + +## Repairs + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| POST | `/api/repairs` | Active tenant assigned to property/unit | See examples below | +| GET | `/api/repairs` | Required | None | +| PUT | `/api/repairs/:id` | Manager for repair property, super admin | See example below | +| DELETE | `/api/repairs/:id` | Manager for repair property, super admin, or creating tenant | None | + +Create repair request without image: + +```json +{ + "propertyId": "property-uuid", + "unit": "A1", + "category": "plumbing", + "description": "Kitchen sink leak" +} +``` + +Create repair request with image: + +```text +propertyId=property-uuid +unit=A1 +category=plumbing +description=Kitchen sink leak +image=@repair.jpg +``` + +Update repair request: + +```json +{ + "status": "in-progress", + "landlordResponse": "Technician assigned", + "technicianDetails": "Plumber arrives today", + "assignedTo": "staff-user-uuid", + "cost": 500 +} +``` + +`category`: `plumbing`, `electricity`, `security`, `general`. + +`status`: `pending`, `in-progress`, `resolved`. + +## Payments + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| GET | `/api/payments` | Required | None | +| POST | `/api/payments/stkpush` | Tenant paying self, manager for property, super admin | `{ "amount": 1, "phoneNumber": "254712345678", "tenantId": "tenant-profile-uuid" }` | +| GET | `/api/payments/settings` | Landlord or super admin | None | +| PUT | `/api/payments/settings` | Landlord or super admin | See example below | +| GET | `/api/payments/methods` | Required | None | +| POST | `/api/payments/callback` | None, M-Pesa callback | See example below | + +Payment settings: + +```json +{ + "mpesaShortcode": "174379", + "mpesaConsumerKey": "consumer-key", + "mpesaConsumerSecret": "consumer-secret", + "mpesaPasskey": "passkey", + "bankDetails": { + "bankName": "Example Bank", + "accountNumber": "1234567890", + "accountName": "Apex Agencies" + }, + "paymentMethods": ["mpesa", "bank_transfer"] +} +``` + +M-Pesa callback: + +```json +{ + "Body": { + "stkCallback": { + "MerchantRequestID": "merchant-id", + "CheckoutRequestID": "checkout-id", + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + { "Name": "MpesaReceiptNumber", "Value": "ABC123" } + ] + } + } + } +} +``` + +## Leases + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| POST | `/api/leases/upload` | Manager for property, super admin | Multipart fields below | +| GET | `/api/leases/tenant` | Required | None | +| GET | `/api/leases/landlord` | Landlord, property manager, super admin | None | +| GET | `/api/leases/view/:id` | Owning tenant, manager for property, super admin | None | +| DELETE | `/api/leases/:id` | Manager for property, super admin | None | + +Lease upload multipart fields: + +```text +tenantId=tenant-user-uuid +propertyId=property-uuid +unit=A1 +startDate=2026-01-01 +endDate=2026-12-31 +depositAmount=12000 +penaltyTerms=Standard terms +lease=@lease.pdf +``` + +## Suggestions + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| POST | `/api/suggestions` | Active tenant | `{ "content": "Please add hallway lighting." }` | +| GET | `/api/suggestions` | Landlord, property manager, super admin | None | + +## Notifications + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| GET | `/api/notifications` | Required | None | +| PATCH | `/api/notifications/:id/read` | Notification owner | None | + +## Reminders + +All reminder routes require landlord, property manager, or super admin. + +| Method | Route | Payload | +| --- | --- | --- | +| POST | `/api/reminders/toggle` | `{ "enabled": true }` | +| POST | `/api/reminders/settings` | `{ "beforeDays": 3, "afterDays": 5 }` | +| POST | `/api/reminders/manual` | `{ "tenantId": "tenant-profile-uuid" }` | +| POST | `/api/reminders/trigger-all` | None | + +## Paystack Webhook + +| Method | Route | Auth | Payload | +| --- | --- | --- | --- | +| POST | `/api/webhooks/paystack` | None, `x-paystack-signature` required | See example below | + +```json +{ + "event": "charge.success", + "data": { + "amount": 50000, + "reference": "paystack-reference", + "metadata": { + "organizationId": "organization-uuid", + "type": "subscription_payment" + } + } +} +``` diff --git a/server/helpers/audit.js b/server/helpers/audit.js index fdf8f6f..0059ba4 100644 --- a/server/helpers/audit.js +++ b/server/helpers/audit.js @@ -56,7 +56,7 @@ const logRequestAudit = async ({ entityId = null, metadata = {} } = {}) => logAudit({ - organization: organization || req?.user?.organizationId || null, + organization: organization || req?.organizationId || req?.user?.organization_id || null, actor: req?.user?.id || null, action, entityType, diff --git a/server/helpers/notifications.js b/server/helpers/notifications.js index b0a8204..fb03a24 100644 --- a/server/helpers/notifications.js +++ b/server/helpers/notifications.js @@ -1,12 +1,27 @@ const { Notification } = require('../models'); -const createNotification = async (payload) => { - if (!payload?.user || !payload?.title || !payload?.message) { +const createNotification = async ({ + schema, + userId, + title, + message, + type = 'system_notice', + channel = 'in_app', + metadata = {} +} = {}) => { + if (!schema || !userId || !title || !message) { return null; } try { - return await Notification.create(payload); + return await Notification.create(schema, { + userId, + title, + message, + type, + channel, + metadata + }); } catch (error) { console.error('Notification creation error:', error.message); return null; diff --git a/server/helpers/organization.js b/server/helpers/organization.js index ff377d2..c9035ec 100644 --- a/server/helpers/organization.js +++ b/server/helpers/organization.js @@ -1,22 +1,32 @@ -const { Organization } = require('../models'); +const { Organization, OrganizationMembership, User } = require('../models'); +const { createTenantSchema } = require('../services/tenantService'); const ensureOrganizationForUser = async (user, fallbackName) => { - if (user.organization) { - return user.organization; + if (user.role === 'super_admin') { + return null; + } + + if (user.organization_id) { + return user.organization_id; } const organization = await Organization.create({ name: fallbackName || `${user.name}'s Portfolio`, - owner: user._id, + ownerId: user.id, status: user.role === 'super_admin' ? 'active' : 'trial', subscriptionPlan: 'basic', billingCycle: 'monthly' }); - user.organization = organization._id; - await user.save(); + await createTenantSchema(organization.schema_name); + await User.update(user.id, { organization_id: organization.id }); + await OrganizationMembership.create({ + userId: user.id, + organizationId: organization.id, + isDefault: true + }); - return organization._id; + return organization.id; }; module.exports = { diff --git a/server/helpers/rbac.js b/server/helpers/rbac.js index ec650a5..6a408f8 100644 --- a/server/helpers/rbac.js +++ b/server/helpers/rbac.js @@ -2,8 +2,6 @@ const ROLES = Object.freeze({ TENANT: 'tenant', LANDLORD: 'landlord', PROPERTY_MANAGER: 'property_manager', - CARETAKER: 'caretaker', - AGENT: 'agent', SUPER_ADMIN: 'super_admin' }); diff --git a/server/helpers/sql.js b/server/helpers/sql.js new file mode 100644 index 0000000..836c656 --- /dev/null +++ b/server/helpers/sql.js @@ -0,0 +1,22 @@ +const normalizeUpdates = (updates = {}, allowedFields = []) => { + const allowed = new Set(allowedFields); + const normalized = {}; + + for (const [field, value] of Object.entries(updates || {})) { + if (value === undefined) continue; + if (!allowed.has(field)) { + throw new Error(`Unsupported update field: ${field}`); + } + normalized[field] = value; + } + + return normalized; +}; + +const buildSetClause = (fields, startIndex = 2) => + fields.map((field, index) => `${field} = $${index + startIndex}`).join(', '); + +module.exports = { + buildSetClause, + normalizeUpdates +}; diff --git a/server/helpers/upload.js b/server/helpers/upload.js index 176ca69..649a5b7 100644 --- a/server/helpers/upload.js +++ b/server/helpers/upload.js @@ -1,51 +1,59 @@ -const fs = require('fs'); -const path = require('path'); +const cloudinary = require('cloudinary').v2; +const { CloudinaryStorage } = require('multer-storage-cloudinary'); const multer = require('multer'); -const { rootDir, uploadsRoot } = require('../config/env'); +const { cloudinary: cloudConfig } = require('../config/env'); -const sanitizeFilename = (filename) => filename.replace(/\s+/g, '-'); +cloudinary.config({ + cloud_name: cloudConfig.cloudName, + api_key: cloudConfig.apiKey, + api_secret: cloudConfig.apiSecret +}); -const ensureUploadDir = (folder) => { - const target = path.join(uploadsRoot, folder); - fs.mkdirSync(target, { recursive: true }); - return target; +const buildCloudinaryStorage = (folder, allowedFormats = ['jpg', 'png', 'jpeg', 'pdf']) => { + return new CloudinaryStorage({ + cloudinary: cloudinary, + params: { + folder: `apex-rentals/${folder}`, + allowed_formats: allowedFormats, + resource_type: 'auto' // Important for PDFs + } + }); }; -const buildStorage = (folder) => multer.diskStorage({ - destination: (req, file, cb) => cb(null, ensureUploadDir(folder)), - filename: (req, file, cb) => cb(null, `${Date.now()}-${sanitizeFilename(file.originalname)}`) -}); - -const createFileFilter = (allowedPattern) => (req, file, cb) => { - if (!allowedPattern) { - cb(null, true); - return; - } +const leaseUpload = multer({ storage: buildCloudinaryStorage('leases') }); +const repairUpload = multer({ storage: buildCloudinaryStorage('repairs') }); +const propertyUpload = multer({ storage: buildCloudinaryStorage('properties') }); + +/** + * Extracts public_id from a Cloudinary URL to use for deletion. + * Example URL: https://res.cloudinary.com/cloudname/image/upload/v12345/apex-rentals/repairs/filename.jpg + * Result: apex-rentals/repairs/filename + */ +const getPublicIdFromUrl = (url) => { + if (!url || !url.includes('cloudinary.com')) return null; + const parts = url.split('/'); + const lastPart = parts.pop(); // filename.jpg + const folderParts = parts.slice(parts.indexOf('apex-rentals')); + const publicIdWithExt = [...folderParts, lastPart].join('/'); + return publicIdWithExt.split('.')[0]; +}; - const extension = path.extname(file.originalname).toLowerCase(); - const isMimeAllowed = allowedPattern.test(file.mimetype); - const isExtensionAllowed = allowedPattern.test(extension); +const deleteFromCloudinary = async (url) => { + const publicId = getPublicIdFromUrl(url); + if (!publicId) return; - if (isMimeAllowed || isExtensionAllowed) { - cb(null, true); - return; + try { + await cloudinary.uploader.destroy(publicId); + console.log(`Deleted from Cloudinary: ${publicId}`); + } catch (error) { + console.error('Cloudinary Deletion Error:', error.message); } - - cb(new Error(`Unsupported file type for ${file.originalname}`)); }; -const createUpload = (folder, allowedPattern) => multer({ - storage: buildStorage(folder), - fileFilter: createFileFilter(allowedPattern) -}); - -const leaseUpload = createUpload('leases', /pdf|doc|docx|jpg|jpeg|png/); -const repairUpload = createUpload('repairs'); - -const toStoredUploadPath = (absolutePath) => path.relative(rootDir, absolutePath).replace(/\\/g, '/'); - module.exports = { leaseUpload, repairUpload, - toStoredUploadPath + propertyUpload, + deleteFromCloudinary, + cloudinary }; diff --git a/server/middleware/auth.js b/server/middleware/auth.js index f88783b..e987817 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -1,15 +1,52 @@ const jwt = require('jsonwebtoken'); const { jwtSecret } = require('../config/env'); +const { User } = require('../models'); +const { ROLES } = require('../helpers/rbac'); +const { AppError } = require('./errorHandler'); -module.exports = (req, res, next) => { - const token = req.header('Authorization'); - if (!token) return res.status(401).json({ message: 'Access denied. No token provided.' }); - +const auth = async (req, res, next) => { try { - const decoded = jwt.verify(token.replace('Bearer ', ''), jwtSecret); - req.user = decoded; + const authHeader = req.headers.authorization || ''; + if (!authHeader.startsWith('Bearer ')) { + throw new AppError('Authentication required', 401); + } + + const token = authHeader.replace('Bearer ', ''); + let decoded; + try { + decoded = jwt.verify(token, jwtSecret); + } catch (err) { + throw new AppError('Invalid token', 401); + } + + const user = await User.findById(decoded.id); + if (!user || !user.is_active) { + throw new AppError('User not found or inactive', 401); + } + + if (user.requires_password_change && req.path !== '/change-password' && req.path !== '/auth/change-password') { + throw new AppError('Password change required before accessing the system', 403, 'PASSWORD_CHANGE_REQUIRED'); + } + + const tokenOrganizationId = Object.prototype.hasOwnProperty.call(decoded, 'organizationId') + ? decoded.organizationId + : undefined; + req.organizationId = user.role === ROLES.SUPER_ADMIN + ? (tokenOrganizationId || null) + : (tokenOrganizationId || user.organization_id || null); + req.user = User.sanitize({ ...user, organization_id: req.organizationId }); + req.userId = user.id; + req.homeOrganizationId = user.organization_id || null; next(); - } catch (ex) { - res.status(400).json({ message: 'Invalid token.' }); + } catch (err) { + next(err); } }; + +const optionalAuth = async (req, res, next) => { + if (!req.headers.authorization) return next(); + return auth(req, res, next); +}; + +module.exports = auth; +module.exports.optionalAuth = optionalAuth; diff --git a/server/middleware/authorize.js b/server/middleware/authorize.js index e4c469e..881577d 100644 --- a/server/middleware/authorize.js +++ b/server/middleware/authorize.js @@ -1,10 +1,9 @@ -const { sendError } = require('../helpers/apiResponse'); const { hasRole } = require('../helpers/rbac'); +const { AppError } = require('./errorHandler'); module.exports = (...allowedRoles) => (req, res, next) => { if (!hasRole(req.user, allowedRoles)) { - sendError(res, 403, 'You do not have permission to access this resource'); - return; + return next(new AppError('You do not have permission to access this resource', 403)); } next(); diff --git a/server/middleware/errorHandler.js b/server/middleware/errorHandler.js index 2d9259a..ed240fc 100644 --- a/server/middleware/errorHandler.js +++ b/server/middleware/errorHandler.js @@ -1,3 +1,17 @@ +class AppError extends Error { + constructor(message, statusCode = 500) { + super(message); + this.statusCode = statusCode; + } +} + +class ValidationError extends AppError { + constructor(errors = []) { + super('Validation failed', 422); + this.errors = errors; + } +} + const notFound = (req, res) => { res.status(404).json({ message: `Route not found: ${req.originalUrl}` }); }; @@ -11,11 +25,14 @@ const errorHandler = (err, req, res, next) => { console.error(err); res.status(err.statusCode || 500).json({ - message: err.message || 'Internal server error' + message: err.message || 'Internal server error', + ...(err.errors ? { errors: err.errors } : {}) }); }; module.exports = { + AppError, + ValidationError, notFound, errorHandler }; diff --git a/server/middleware/rateLimiter.js b/server/middleware/rateLimiter.js new file mode 100644 index 0000000..66c6013 --- /dev/null +++ b/server/middleware/rateLimiter.js @@ -0,0 +1,14 @@ +const rateLimit = require('express-rate-limit'); +const { rateLimit: rateConfig } = require('../config/env'); + +const apiRateLimiter = rateLimit({ + windowMs: rateConfig.windowMs, + max: rateConfig.max, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many requests, please try again later.' } +}); + +module.exports = { + apiRateLimiter +}; diff --git a/server/middleware/tenantResolver.js b/server/middleware/tenantResolver.js new file mode 100644 index 0000000..506d7ef --- /dev/null +++ b/server/middleware/tenantResolver.js @@ -0,0 +1,63 @@ +const { Organization, OrganizationMembership, Tenant } = require('../models'); +const { ROLES } = require('../helpers/rbac'); +const { schemaExists } = require('../services/tenantService'); +const { AppError } = require('./errorHandler'); + +const tenantResolver = async (req, res, next) => { + try { + if (!req.organizationId) { + throw new AppError('Organization context required', 400); + } + + if (req.userId && req.user?.role !== 'super_admin') { + let membership = await OrganizationMembership.findByUserAndOrganization(req.userId, req.organizationId); + if (!membership && req.homeOrganizationId === req.organizationId) { + membership = await OrganizationMembership.create({ + userId: req.userId, + organizationId: req.organizationId, + isDefault: true + }); + } + if (!membership) { + throw new AppError('This account does not belong to the selected organization', 403); + } + } + + const organization = await Organization.findById(req.organizationId); + if (!organization) { + throw new AppError('Organization not found', 404); + } + + if (!['trial', 'active'].includes(organization.status)) { + throw new AppError('Organization account is not active', 403); + } + + if (!organization.schema_name || !(await schemaExists(organization.schema_name))) { + throw new AppError('Organization workspace is not initialized', 503); + } + + req.organization = organization; + req.schema = organization.schema_name; + req.user = req.user ? { ...req.user, organization_id: organization.id } : req.user; + + if (req.userId && req.user?.role === ROLES.TENANT) { + const tenant = await Tenant.findOne(req.schema, { + userId: req.userId, + status: 'active' + }); + if (!tenant) { + throw new AppError('Active tenant profile not found for this organization', 403); + } + req.tenant = tenant; + req.tenantId = tenant.id; + } + + next(); + } catch (err) { + next(err); + } +}; + +module.exports = { + tenantResolver +}; diff --git a/server/middleware/validation.js b/server/middleware/validation.js new file mode 100644 index 0000000..fffc423 --- /dev/null +++ b/server/middleware/validation.js @@ -0,0 +1,141 @@ +const Joi = require('joi'); +const { ValidationError } = require('./errorHandler'); +const { ROLES } = require('../helpers/rbac'); + +const validate = (schema, source = 'body') => (req, res, next) => { + const payload = source === 'body' ? req.body : source === 'query' ? req.query : req.params; + const { error, value } = schema.validate(payload, { abortEarly: false, stripUnknown: true }); + + if (error) { + const errors = error.details.map((detail) => ({ + field: detail.path.join('.'), + message: detail.message + })); + return next(new ValidationError(errors)); + } + + if (source === 'body') req.body = value; + if (source === 'query') req.query = value; + next(); +}; + +const schemas = { + register: Joi.object({ + name: Joi.string().min(2).max(200).required(), + email: Joi.string().email().required(), + password: Joi.string().min(8).required(), + role: Joi.string().valid(...Object.values(ROLES)).required(), + phoneNumber: Joi.string().optional().allow(''), + interestedProperty: Joi.when('role', { + is: ROLES.TENANT, + then: Joi.string().uuid().required(), + otherwise: Joi.string().uuid().optional().allow(null, '') + }), + interestedUnit: Joi.string().optional().allow(''), + organizationId: Joi.string().uuid().optional().allow(null, '') + }), + inquiry: Joi.object({ + name: Joi.string().min(2).max(200).required(), + email: Joi.string().email().required(), + phoneNumber: Joi.string().optional().allow(''), + interestedProperty: Joi.string().uuid().required(), + interestedUnit: Joi.string().optional().allow(''), + organizationId: Joi.string().uuid().optional().allow(null, '') + }), + login: Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().required(), + organizationId: Joi.string().uuid().optional().allow(null, '') + }), + createProperty: Joi.object({ + name: Joi.string().min(2).max(200).required(), + address: Joi.string().min(2).max(200).required(), + description: Joi.string().allow('').optional(), + type: Joi.string().allow('').optional(), + units: Joi.alternatives().try( + Joi.array().items(Joi.string().min(1)).default([]), + Joi.string().allow('') + ).optional(), + landlord: Joi.string().uuid().optional().allow(null, ''), + manager: Joi.string().uuid().optional().allow(null, '') + }), + updateProperty: Joi.object({ + name: Joi.string().min(2).max(200).optional(), + address: Joi.string().min(2).max(200).optional(), + description: Joi.string().allow('').optional(), + type: Joi.string().allow('').optional(), + units: Joi.alternatives().try( + Joi.array().items(Joi.string().min(1)), + Joi.string().allow('') + ).optional(), + landlord: Joi.string().uuid().optional().allow(null, ''), + manager: Joi.string().uuid().optional().allow(null, '') + }), + createTenant: Joi.object({ + user: Joi.string().uuid().required(), + property: Joi.string().uuid().required(), + unit: Joi.string().required(), + rentAmount: Joi.number().min(0).required(), + dueDate: Joi.number().min(1).max(31).optional(), + status: Joi.string().valid('active', 'defaulted', 'moved_out', 'pending').optional(), + leaseStart: Joi.date().optional(), + leaseEnd: Joi.date().optional() + }), + createUnit: Joi.object({ + propertyId: Joi.string().uuid().required(), + unitNumber: Joi.string().required(), + rentAmount: Joi.number().min(0).optional(), + occupancyStatus: Joi.string().valid('vacant', 'occupied', 'reserved', 'maintenance').optional(), + meterReadings: Joi.object({ + water: Joi.number().min(0).optional(), + electricity: Joi.number().min(0).optional() + }).optional() + }), + updateUnit: Joi.object({ + unitNumber: Joi.string().optional(), + rentAmount: Joi.number().min(0).optional(), + occupancyStatus: Joi.string().valid('vacant', 'occupied', 'reserved', 'maintenance').optional(), + meterReadings: Joi.object({ + water: Joi.number().min(0).optional(), + electricity: Joi.number().min(0).optional() + }).optional(), + isActive: Joi.boolean().optional() + }), + repairRequest: Joi.object({ + propertyId: Joi.string().uuid().required(), + unit: Joi.string().required(), + category: Joi.string().valid('plumbing', 'electricity', 'security', 'general').optional(), + description: Joi.string().min(3).required() + }), + repairUpdate: Joi.object({ + status: Joi.string().valid('pending', 'in-progress', 'resolved').optional(), + landlordResponse: Joi.string().allow('').optional(), + technicianDetails: Joi.string().allow('').optional(), + assignedTo: Joi.string().uuid().optional().allow(null, ''), + cost: Joi.number().min(0).optional() + }), + suggestion: Joi.object({ + content: Joi.string().min(3).required() + }), + message: Joi.object({ + content: Joi.string().min(1).required(), + propertyId: Joi.string().uuid().optional().allow(null, '') + }), + paymentStk: Joi.object({ + amount: Joi.number().positive().required(), + phoneNumber: Joi.string().required(), + tenantId: Joi.string().uuid().required() + }), + approveRegistration: Joi.object({ + rentAmount: Joi.number().min(0).required(), + depositAmount: Joi.number().min(0).optional().default(0), + dueDate: Joi.number().min(1).max(31).optional().default(1), + leaseStart: Joi.date().optional(), + leaseEnd: Joi.date().optional() + }) + }; + +module.exports = { + validate, + schemas +}; diff --git a/server/models/AuditLog.js b/server/models/AuditLog.js index 33baf12..908a451 100644 --- a/server/models/AuditLog.js +++ b/server/models/AuditLog.js @@ -1,13 +1,42 @@ -const mongoose = require('mongoose'); - -const auditLogSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - actor: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, - action: { type: String, required: true, trim: true }, - entityType: { type: String, trim: true }, - entityId: { type: mongoose.Schema.Types.ObjectId }, - metadata: { type: mongoose.Schema.Types.Mixed }, - createdAt: { type: Date, default: Date.now } -}); - -module.exports = mongoose.models.AuditLog || mongoose.model('AuditLog', auditLogSchema); +const { query } = require('../config/database'); + +class AuditLog { + static async create({ + organization = null, + actor = null, + action, + entityType = '', + entityId = null, + metadata = {} + }) { + const res = await query( + `INSERT INTO public.audit_logs + (organization_id, actor_id, action, entity_type, entity_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [organization, actor, action, entityType, entityId, metadata] + ); + return res.rows[0]; + } + + static async count() { + const res = await query('SELECT COUNT(*) FROM public.audit_logs'); + return Number(res.rows[0]?.count || 0); + } + + static async list(limit = 60) { + const res = await query( + `SELECT al.*, u.name AS actor_name, u.email AS actor_email, u.role AS actor_role, + o.name AS organization_name, o.status AS organization_status, o.subscription_plan + FROM public.audit_logs al + LEFT JOIN public.users u ON u.id = al.actor_id + LEFT JOIN public.organizations o ON o.id = al.organization_id + ORDER BY al.created_at DESC + LIMIT $1`, + [limit] + ); + return res.rows; + } +} + +module.exports = AuditLog; diff --git a/server/models/Invoice.js b/server/models/Invoice.js index c74c4fa..04ea782 100644 --- a/server/models/Invoice.js +++ b/server/models/Invoice.js @@ -1,25 +1,47 @@ -const mongoose = require('mongoose'); +const { tenantQuery } = require('../config/database'); -const invoiceSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - tenant: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant', required: true }, - property: { type: mongoose.Schema.Types.ObjectId, ref: 'Property' }, - unit: { type: String, trim: true }, - amount: { type: Number, required: true }, - utilities: { type: Number, default: 0 }, - penalties: { type: Number, default: 0 }, - totalAmount: { type: Number, required: true }, - status: { - type: String, - enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'], - default: 'draft' - }, - dueDate: { type: Date, required: true }, - issuedAt: { type: Date, default: Date.now }, - lineItems: [{ - label: { type: String, trim: true }, - amount: { type: Number, default: 0 } - }] -}, { timestamps: true }); +class Invoice { + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO invoices + (tenant_id, property_id, unit, amount, utilities, penalties, total_amount, status, due_date, issued_at, line_items) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING *`, + [ + data.tenantId, + data.propertyId || null, + data.unit || null, + data.amount, + data.utilities || 0, + data.penalties || 0, + data.totalAmount, + data.status || 'draft', + data.dueDate, + data.issuedAt || new Date(), + data.lineItems || [] + ] + ); + return res.rows[0]; + } -module.exports = mongoose.models.Invoice || mongoose.model('Invoice', invoiceSchema); + static async find(schema, filter = {}) { + const conditions = []; + const values = []; + + if (filter.tenantId) { + values.push(filter.tenantId); + conditions.push(`tenant_id = $${values.length}`); + } + + if (filter.status) { + values.push(filter.status); + conditions.push(`status = $${values.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const res = await tenantQuery(schema, `SELECT * FROM invoices ${where}`, values); + return res.rows; + } +} + +module.exports = Invoice; diff --git a/server/models/Lease.js b/server/models/Lease.js index 896d3cc..99caadf 100644 --- a/server/models/Lease.js +++ b/server/models/Lease.js @@ -1,18 +1,58 @@ -const mongoose = require('mongoose'); +const { tenantQuery } = require('../config/database'); -const leaseSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - tenant: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, - property: { type: mongoose.Schema.Types.ObjectId, ref: 'Property', required: true }, - unit: { type: String, required: true, trim: true }, - filePath: { type: String, required: true }, - fileName: { type: String, required: true }, - startDate: { type: Date }, - endDate: { type: Date }, - depositAmount: { type: Number, default: 0 }, - penaltyTerms: { type: String }, - signedAt: { type: Date }, - uploadedAt: { type: Date, default: Date.now } -}); +class Lease { + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO leases + (tenant_id, property_id, unit, file_path, file_name, start_date, end_date, deposit_amount, penalty_terms, signed_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [ + data.tenantId, + data.propertyId, + data.unit, + data.filePath, + data.fileName, + data.startDate || null, + data.endDate || null, + data.depositAmount || 0, + data.penaltyTerms || null, + data.signedAt || null + ] + ); + return res.rows[0]; + } -module.exports = mongoose.models.Lease || mongoose.model('Lease', leaseSchema); + static async findByTenant(schema, tenantUserId) { + const res = await tenantQuery(schema, 'SELECT * FROM leases WHERE tenant_id = $1', [tenantUserId]); + return res.rows[0] || null; + } + + static async findById(schema, id) { + const res = await tenantQuery(schema, 'SELECT * FROM leases WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async findByPropertyIds(schema, propertyIds) { + if (!propertyIds?.length) return []; + const res = await tenantQuery(schema, + 'SELECT * FROM leases WHERE property_id = ANY($1::uuid[])', + [propertyIds] + ); + return res.rows; + } + + static async findByTenantAndUnit(schema, tenantUserId, propertyId, unit) { + const res = await tenantQuery(schema, + `SELECT * FROM leases WHERE tenant_id = $1 AND property_id = $2 AND unit = $3`, + [tenantUserId, propertyId, unit] + ); + return res.rows[0] || null; + } + + static async delete(schema, id) { + await tenantQuery(schema, 'DELETE FROM leases WHERE id = $1', [id]); + } +} + +module.exports = Lease; diff --git a/server/models/Message.js b/server/models/Message.js index 2026c0f..81a7170 100644 --- a/server/models/Message.js +++ b/server/models/Message.js @@ -1,11 +1,29 @@ -const mongoose = require('mongoose'); +const { tenantQuery } = require('../config/database'); -const messageSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - sender: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, - recipient: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, - content: { type: String, required: true, trim: true }, - timestamp: { type: Date, default: Date.now } -}); +class Message { + static async list(schema, propertyId = null) { + const query = propertyId + ? 'SELECT * FROM messages WHERE property_id = $1 ORDER BY timestamp ASC' + : 'SELECT * FROM messages WHERE property_id IS NULL ORDER BY timestamp ASC'; + const values = propertyId ? [propertyId] : []; + const res = await tenantQuery(schema, query, values); + return res.rows; + } -module.exports = mongoose.models.Message || mongoose.model('Message', messageSchema); + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO messages (sender_id, recipient_id, content, property_id) + VALUES ($1,$2,$3,$4) + RETURNING *`, + [data.senderId, data.recipientId || null, data.content, data.propertyId || null] + ); + return res.rows[0]; + } + + static async findById(schema, id) { + const res = await tenantQuery(schema, 'SELECT * FROM messages WHERE id = $1', [id]); + return res.rows[0] || null; + } +} + +module.exports = Message; diff --git a/server/models/MpesaTransaction.js b/server/models/MpesaTransaction.js index dfd5b54..68de58f 100644 --- a/server/models/MpesaTransaction.js +++ b/server/models/MpesaTransaction.js @@ -1,22 +1,63 @@ -const mongoose = require('mongoose'); +const { tenantQuery } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); -const mpesaTransactionSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - tenant: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant' }, - merchantRequestId: { type: String }, - checkoutRequestId: { type: String, unique: true, sparse: true }, - phoneNumber: { type: String }, - amount: { type: Number }, - mpesaReceiptNumber: { type: String }, - transactionDate: { type: Date }, - resultCode: { type: Number }, - resultDesc: { type: String }, - status: { - type: String, - enum: ['pending', 'completed', 'failed'], - default: 'pending' - }, - createdAt: { type: Date, default: Date.now } -}); +const MPESA_TRANSACTION_UPDATE_FIELDS = [ + 'tenant_id', + 'merchant_request_id', + 'checkout_request_id', + 'phone_number', + 'amount', + 'mpesa_receipt_number', + 'transaction_date', + 'result_code', + 'result_desc', + 'status' +]; -module.exports = mongoose.models.MpesaTransaction || mongoose.model('MpesaTransaction', mpesaTransactionSchema); +class MpesaTransaction { + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO mpesa_transactions + (tenant_id, merchant_request_id, checkout_request_id, phone_number, amount, status) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING *`, + [ + data.tenantId || null, + data.merchantRequestId || null, + data.checkoutRequestId || null, + data.phoneNumber || null, + data.amount || null, + data.status || 'pending' + ] + ); + return res.rows[0]; + } + + static async findByCheckoutRequestId(schema, checkoutRequestId) { + const res = await tenantQuery(schema, + 'SELECT * FROM mpesa_transactions WHERE checkout_request_id = $1', + [checkoutRequestId] + ); + return res.rows[0] || null; + } + + static async update(schema, id, updates) { + const normalized = normalizeUpdates(updates, MPESA_TRANSACTION_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return null; + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const res = await tenantQuery(schema, + `UPDATE mpesa_transactions SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async findById(schema, id) { + const res = await tenantQuery(schema, 'SELECT * FROM mpesa_transactions WHERE id = $1', [id]); + return res.rows[0] || null; + } +} + +module.exports = MpesaTransaction; diff --git a/server/models/Notification.js b/server/models/Notification.js index b76ba35..85adc11 100644 --- a/server/models/Notification.js +++ b/server/models/Notification.js @@ -1,22 +1,41 @@ -const mongoose = require('mongoose'); +const { tenantQuery } = require('../config/database'); -const notificationSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, - title: { type: String, required: true, trim: true }, - message: { type: String, required: true, trim: true }, - type: { - type: String, - enum: ['rent_due', 'payment_confirmation', 'maintenance_update', 'lease_expiry', 'system_notice'], - default: 'system_notice' - }, - channel: { - type: String, - enum: ['sms', 'email', 'in_app'], - default: 'in_app' - }, - isRead: { type: Boolean, default: false }, - metadata: { type: mongoose.Schema.Types.Mixed } -}, { timestamps: true }); +class Notification { + static async findByUser(schema, userId) { + const res = await tenantQuery(schema, + 'SELECT * FROM notifications WHERE user_id = $1 ORDER BY created_at DESC', + [userId] + ); + return res.rows; + } -module.exports = mongoose.models.Notification || mongoose.model('Notification', notificationSchema); + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO notifications + (user_id, title, message, type, channel, is_read, metadata) + VALUES ($1,$2,$3,$4,$5,$6,$7) + RETURNING *`, + [ + data.userId, + data.title, + data.message, + data.type || 'system_notice', + data.channel || 'in_app', + data.isRead || false, + data.metadata || {} + ] + ); + return res.rows[0]; + } + + static async markRead(schema, id, userId) { + const res = await tenantQuery(schema, + `UPDATE notifications SET is_read = TRUE, updated_at = NOW() + WHERE id = $1 AND user_id = $2 RETURNING *`, + [id, userId] + ); + return res.rows[0] || null; + } +} + +module.exports = Notification; diff --git a/server/models/Organization.js b/server/models/Organization.js index 766645a..1dbdf09 100644 --- a/server/models/Organization.js +++ b/server/models/Organization.js @@ -1,31 +1,112 @@ -const mongoose = require('mongoose'); - -const organizationSchema = new mongoose.Schema({ - name: { type: String, required: true, trim: true }, - owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, - status: { - type: String, - enum: ['trial', 'active', 'suspended', 'inactive'], - default: 'trial' - }, - subscriptionPlan: { - type: String, - enum: ['basic', 'pro', 'enterprise'], - default: 'basic' - }, - billingCycle: { - type: String, - enum: ['monthly', 'yearly'], - default: 'monthly' - }, - settings: { - currency: { type: String, default: 'KSh' }, - notifications: { - sms: { type: Boolean, default: true }, - email: { type: Boolean, default: true }, - inApp: { type: Boolean, default: true } - } +const { query } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); + +const ORGANIZATION_UPDATE_FIELDS = [ + 'name', + 'owner_id', + 'status', + 'subscription_plan', + 'billing_cycle', + 'settings', + 'schema_name', + 'auto_reminders_enabled', + 'reminder_settings', + 'global_chat_enabled', + 'mpesa_shortcode', + 'mpesa_consumer_key', + 'mpesa_consumer_secret', + 'mpesa_passkey', + 'bank_details', + 'payment_methods', + 'price_per_unit', + 'billing_cycle_months' +]; + +const normalizeSchemaName = (name) => { + const suffix = Date.now().toString(36); + const prefix = 'tenant_'; + const slug = String(name || 'org') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'org'; + const maxSlugLength = 63 - prefix.length - suffix.length - 1; + return `${prefix}${slug.slice(0, maxSlugLength)}_${suffix}`; +}; + +class Organization { + static async create({ + name, + ownerId = null, + status = 'trial', + subscriptionPlan = 'basic', + billingCycle = 'monthly', + settings = {}, + autoRemindersEnabled = false, + reminderSettings = { before_days: 3, after_days: 3 }, + globalChatEnabled = false, + mpesaShortcode = null, + mpesaConsumerKey = null, + mpesaConsumerSecret = null, + mpesaPasskey = null, + bankDetails = {}, + paymentMethods = ['mpesa'], + pricePerUnit = 500.00, + billingCycleMonths = 1 + }, client = null) { + const schemaName = normalizeSchemaName(name); + const executor = client || { query }; + const res = await executor.query( + `INSERT INTO public.organizations + (name, owner_id, status, subscription_plan, billing_cycle, settings, schema_name, + auto_reminders_enabled, reminder_settings, global_chat_enabled, + mpesa_shortcode, mpesa_consumer_key, mpesa_consumer_secret, mpesa_passkey, bank_details, payment_methods, + price_per_unit, billing_cycle_months) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + RETURNING *`, + [ + name, ownerId, status, subscriptionPlan, billingCycle, settings, schemaName, + autoRemindersEnabled, reminderSettings, globalChatEnabled, + mpesaShortcode, mpesaConsumerKey, mpesaConsumerSecret, mpesaPasskey, bankDetails, paymentMethods, + pricePerUnit, billingCycleMonths + ] + ); + return res.rows[0]; + } + + static async update(id, updates, client = null) { + const normalized = normalizeUpdates(updates, ORGANIZATION_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return this.findById(id); + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const executor = client || { query }; + const res = await executor.query( + `UPDATE public.organizations SET ${setClause}, updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async findById(id) { + const res = await query('SELECT * FROM public.organizations WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async findAll() { + const res = await query( + `SELECT o.*, u.name AS owner_name, u.email AS owner_email, u.role AS owner_role + FROM public.organizations o + LEFT JOIN public.users u ON o.owner_id = u.id + ORDER BY o.created_at DESC` + ); + return res.rows; + } + + static async count() { + const res = await query('SELECT COUNT(*) FROM public.organizations'); + return Number(res.rows[0]?.count || 0); } -}, { timestamps: true }); +} -module.exports = mongoose.models.Organization || mongoose.model('Organization', organizationSchema); +module.exports = Organization; diff --git a/server/models/OrganizationMembership.js b/server/models/OrganizationMembership.js new file mode 100644 index 0000000..c5dc5df --- /dev/null +++ b/server/models/OrganizationMembership.js @@ -0,0 +1,97 @@ +const { query } = require('../config/database'); + +class OrganizationMembership { + static async create({ userId, organizationId, isDefault = false }, client = null) { + const executor = client || { query }; + + if (isDefault) { + await executor.query( + 'UPDATE public.organization_memberships SET is_default = FALSE WHERE user_id = $1', + [userId] + ); + } + + const res = await executor.query( + `INSERT INTO public.organization_memberships (user_id, organization_id, is_default) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, organization_id) + DO UPDATE SET + is_default = public.organization_memberships.is_default OR EXCLUDED.is_default, + updated_at = NOW() + RETURNING *`, + [userId, organizationId, isDefault] + ); + + if (isDefault) { + await executor.query( + 'UPDATE public.users SET organization_id = $2, updated_at = NOW() WHERE id = $1', + [userId, organizationId] + ); + } + + return res.rows[0]; + } + + static async findByUserAndOrganization(userId, organizationId) { + const res = await query( + `SELECT om.*, o.id, o.name, o.status, o.schema_name + FROM public.organization_memberships om + JOIN public.organizations o ON o.id = om.organization_id + WHERE om.user_id = $1 AND om.organization_id = $2`, + [userId, organizationId] + ); + return res.rows[0] || null; + } + + static async listByUser(userId) { + const res = await query( + `SELECT om.*, o.id, o.name, o.status, o.schema_name + FROM public.organization_memberships om + JOIN public.organizations o ON o.id = om.organization_id + WHERE om.user_id = $1 + ORDER BY om.is_default DESC, o.created_at DESC`, + [userId] + ); + return res.rows; + } + + static async setDefault(userId, organizationId) { + await query( + 'UPDATE public.organization_memberships SET is_default = FALSE WHERE user_id = $1', + [userId] + ); + + const res = await query( + `UPDATE public.organization_memberships + SET is_default = TRUE, updated_at = NOW() + WHERE user_id = $1 AND organization_id = $2 + RETURNING *`, + [userId, organizationId] + ); + + if (!res.rows[0]) { + return null; + } + + await query( + 'UPDATE public.users SET organization_id = $2, updated_at = NOW() WHERE id = $1', + [userId, organizationId] + ); + + return res.rows[0]; + } + + static async listByOrganization(organizationId) { + const res = await query( + `SELECT om.*, u.name, u.email, u.role, u.status, u.phone_number + FROM public.organization_memberships om + JOIN public.users u ON u.id = om.user_id + WHERE om.organization_id = $1 + ORDER BY u.role, u.name`, + [organizationId] + ); + return res.rows; + } +} + +module.exports = OrganizationMembership; diff --git a/server/models/Payment.js b/server/models/Payment.js index 1c2894b..3e097b6 100644 --- a/server/models/Payment.js +++ b/server/models/Payment.js @@ -1,25 +1,92 @@ -const mongoose = require('mongoose'); - -const paymentSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - tenant: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant', required: true }, - invoice: { type: mongoose.Schema.Types.ObjectId, ref: 'Invoice' }, - amount: { type: Number, required: true }, - currency: { type: String, default: 'KSh' }, - method: { - type: String, - enum: ['mpesa', 'bank_transfer', 'card', 'cash', 'other'], - default: 'mpesa' - }, - reference: { type: String }, - status: { - type: String, - enum: ['paid', 'pending', 'overdue', 'failed'], - default: 'pending' - }, - paymentDate: { type: Date }, - dueDate: { type: Date, required: true }, - mpesaTransaction: { type: mongoose.Schema.Types.ObjectId, ref: 'MpesaTransaction' } -}, { timestamps: true }); - -module.exports = mongoose.models.Payment || mongoose.model('Payment', paymentSchema); +const { tenantQuery } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); + +const PAYMENT_UPDATE_FIELDS = [ + 'tenant_id', + 'invoice_id', + 'amount', + 'currency', + 'method', + 'reference', + 'status', + 'payment_date', + 'due_date', + 'mpesa_transaction_id' +]; + +class Payment { + static async findByTenantIds(schema, tenantIds) { + if (!tenantIds?.length) return []; + const res = await tenantQuery(schema, + `SELECT * FROM payments WHERE tenant_id = ANY($1::uuid[]) ORDER BY created_at DESC`, + [tenantIds] + ); + return res.rows; + } + + static async findByTenantId(schema, tenantId) { + const res = await tenantQuery(schema, + `SELECT * FROM payments WHERE tenant_id = $1 ORDER BY created_at DESC`, + [tenantId] + ); + return res.rows; + } + + static async findPendingByTenant(schema, tenantId) { + const res = await tenantQuery(schema, + `SELECT * FROM payments WHERE tenant_id = $1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1`, + [tenantId] + ); + return res.rows[0] || null; + } + + static async findByMpesaTransactionId(schema, mpesaTransactionId) { + const res = await tenantQuery(schema, + 'SELECT * FROM payments WHERE mpesa_transaction_id = $1 ORDER BY created_at DESC LIMIT 1', + [mpesaTransactionId] + ); + return res.rows[0] || null; + } + + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO payments + (tenant_id, invoice_id, amount, currency, method, reference, status, payment_date, due_date, mpesa_transaction_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [ + data.tenantId, + data.invoiceId || null, + data.amount, + data.currency || 'KSh', + data.method || 'mpesa', + data.reference || null, + data.status || 'pending', + data.paymentDate || null, + data.dueDate, + data.mpesaTransactionId || null + ] + ); + return res.rows[0]; + } + + static async update(schema, id, updates) { + const normalized = normalizeUpdates(updates, PAYMENT_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return null; + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const res = await tenantQuery(schema, + `UPDATE payments SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async countPaid(schema) { + const res = await tenantQuery(schema, `SELECT COUNT(*) FROM payments WHERE status = 'paid'`); + return Number(res.rows[0]?.count || 0); + } +} + +module.exports = Payment; diff --git a/server/models/Property.js b/server/models/Property.js index f9eef8e..ad6aac1 100644 --- a/server/models/Property.js +++ b/server/models/Property.js @@ -1,16 +1,98 @@ -const mongoose = require('mongoose'); - -const propertySchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - name: { type: String, required: true, trim: true }, - address: { type: String, required: true, trim: true }, - description: { type: String, trim: true }, - type: { type: String, default: 'apartment', trim: true }, - landlord: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, - manager: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, - caretakers: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], - units: [{ type: String, trim: true }], - images: [{ type: String }] -}, { timestamps: true }); - -module.exports = mongoose.models.Property || mongoose.model('Property', propertySchema); +const { tenantQuery } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); + +const PROPERTY_UPDATE_FIELDS = [ + 'name', + 'address', + 'description', + 'type', + 'landlord_id', + 'manager_id', + 'units', + 'images' +]; + +class Property { + static async find(schema, filter = {}) { + const conditions = []; + const ownershipConditions = []; + const values = []; + + if (filter.landlordId) { + values.push(filter.landlordId); + ownershipConditions.push(`landlord_id = $${values.length}`); + } + + if (filter.managerId) { + values.push(filter.managerId); + ownershipConditions.push(`manager_id = $${values.length}`); + } + + if (ownershipConditions.length) { + conditions.push(`(${ownershipConditions.join(' OR ')})`); + } + + if (filter.ids?.length) { + values.push(filter.ids); + conditions.push(`id = ANY($${values.length})`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const res = await tenantQuery(schema, `SELECT * FROM properties ${where} ORDER BY created_at DESC`, values); + return res.rows; + } + + static async findById(schema, id) { + const res = await tenantQuery(schema, 'SELECT * FROM properties WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async findByName(schema, name) { + const res = await tenantQuery(schema, 'SELECT * FROM properties WHERE name = $1', [name]); + return res.rows[0] || null; + } + + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO properties + (name, address, description, type, landlord_id, manager_id, units, images) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING *`, + [ + data.name, + data.address, + data.description || null, + data.type || 'apartment', + data.landlordId, + data.managerId || null, + data.units || [], + data.images || [] + ] + ); + return res.rows[0]; + } + + static async update(schema, id, updates) { + const normalized = normalizeUpdates(updates, PROPERTY_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return this.findById(schema, id); + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const res = await tenantQuery(schema, + `UPDATE properties SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async delete(schema, id) { + await tenantQuery(schema, 'DELETE FROM properties WHERE id = $1', [id]); + } + + static async count(schema) { + const res = await tenantQuery(schema, 'SELECT COUNT(*) FROM properties'); + return Number(res.rows[0]?.count || 0); + } +} + +module.exports = Property; diff --git a/server/models/RepairRequest.js b/server/models/RepairRequest.js index 42da4ce..cd93b7f 100644 --- a/server/models/RepairRequest.js +++ b/server/models/RepairRequest.js @@ -1,27 +1,91 @@ -const mongoose = require('mongoose'); - -const repairRequestSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - tenant: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, - property: { type: mongoose.Schema.Types.ObjectId, ref: 'Property', required: true }, - unit: { type: String, required: true, trim: true }, - category: { - type: String, - enum: ['plumbing', 'electricity', 'security', 'general'], - default: 'general' - }, - description: { type: String, required: true, trim: true }, - status: { - type: String, - enum: ['pending', 'in-progress', 'resolved'], - default: 'pending' - }, - assignedTo: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, - landlordResponse: { type: String }, - technicianDetails: { type: String }, - imagePath: { type: String }, - cost: { type: Number, default: 0 }, - createdAt: { type: Date, default: Date.now } -}); - -module.exports = mongoose.models.RepairRequest || mongoose.model('RepairRequest', repairRequestSchema); +const { tenantQuery } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); + +const REPAIR_REQUEST_UPDATE_FIELDS = [ + 'tenant_id', + 'property_id', + 'unit', + 'category', + 'description', + 'status', + 'assigned_to', + 'landlord_response', + 'technician_details', + 'image_path', + 'cost' +]; + +class RepairRequest { + static async findByPropertyIds(schema, propertyIds) { + if (!propertyIds?.length) return []; + const res = await tenantQuery(schema, + 'SELECT * FROM repair_requests WHERE property_id = ANY($1::uuid[])', + [propertyIds] + ); + return res.rows; + } + + static async findByAssignedTo(schema, userId) { + const res = await tenantQuery(schema, + 'SELECT * FROM repair_requests WHERE assigned_to = $1', + [userId] + ); + return res.rows; + } + + static async findByTenant(schema, userId) { + const res = await tenantQuery(schema, + 'SELECT * FROM repair_requests WHERE tenant_id = $1', + [userId] + ); + return res.rows; + } + + static async findById(schema, id) { + const res = await tenantQuery(schema, 'SELECT * FROM repair_requests WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO repair_requests + (tenant_id, property_id, unit, category, description, status, assigned_to, landlord_response, + technician_details, image_path, cost) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING *`, + [ + data.tenantId, + data.propertyId, + data.unit, + data.category || 'general', + data.description, + data.status || 'pending', + data.assignedTo || null, + data.landlordResponse || null, + data.technicianDetails || null, + data.imagePath || null, + data.cost || 0 + ] + ); + return res.rows[0]; + } + + static async update(schema, id, updates) { + const normalized = normalizeUpdates(updates, REPAIR_REQUEST_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return null; + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const res = await tenantQuery(schema, + `UPDATE repair_requests SET ${setClause} WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async delete(schema, id) { + await tenantQuery(schema, 'DELETE FROM repair_requests WHERE id = $1', [id]); + } +} + +module.exports = RepairRequest; diff --git a/server/models/Subscription.js b/server/models/Subscription.js index 4aab189..fa97a6d 100644 --- a/server/models/Subscription.js +++ b/server/models/Subscription.js @@ -1,27 +1,76 @@ -const mongoose = require('mongoose'); +const { query } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); -const subscriptionSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true }, - plan: { - type: String, - enum: ['basic', 'pro', 'enterprise'], - default: 'basic' - }, - billingCycle: { - type: String, - enum: ['monthly', 'yearly'], - default: 'monthly' - }, - status: { - type: String, - enum: ['trial', 'active', 'past_due', 'cancelled', 'suspended'], - default: 'trial' - }, - provider: { type: String, default: 'manual' }, - providerSubscriptionId: { type: String }, - startedAt: { type: Date, default: Date.now }, - endsAt: { type: Date }, - trialEndsAt: { type: Date } -}, { timestamps: true }); +const SUBSCRIPTION_UPDATE_FIELDS = [ + 'organization_id', + 'plan', + 'billing_cycle', + 'status', + 'provider', + 'provider_subscription_id', + 'started_at', + 'ends_at', + 'trial_ends_at', + 'next_billing_date', + 'last_billed_at', + 'billable_units_at_last_bill' +]; -module.exports = mongoose.models.Subscription || mongoose.model('Subscription', subscriptionSchema); +class Subscription { + static async create({ + organizationId, + plan = 'basic', + billingCycle = 'monthly', + status = 'trial', + provider = 'manual', + providerSubscriptionId = null, + startedAt = new Date(), + endsAt = null, + trialEndsAt = null, + nextBillingDate = null + }, client = null) { + const executor = client || { query }; + const res = await executor.query( + `INSERT INTO public.subscriptions + (organization_id, plan, billing_cycle, status, provider, provider_subscription_id, started_at, ends_at, trial_ends_at, next_billing_date) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [organizationId, plan, billingCycle, status, provider, providerSubscriptionId, startedAt, endsAt, trialEndsAt, nextBillingDate] + ); + return res.rows[0]; + } + + static async update(id, updates, client = null) { + const normalized = normalizeUpdates(updates, SUBSCRIPTION_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return this.findById(id); + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const executor = client || { query }; + const res = await executor.query( + `UPDATE public.subscriptions SET ${setClause}, updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async findById(id) { + const res = await query('SELECT * FROM public.subscriptions WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async findByOrganization(organizationId) { + const res = await query('SELECT * FROM public.subscriptions WHERE organization_id = $1 ORDER BY created_at DESC', [organizationId]); + return res.rows[0] || null; + } + + static async countActive() { + const res = await query( + `SELECT COUNT(*) FROM public.subscriptions WHERE status IN ('trial', 'active')` + ); + return Number(res.rows[0]?.count || 0); + } +} + +module.exports = Subscription; diff --git a/server/models/Suggestion.js b/server/models/Suggestion.js index fe3da1d..0a91690 100644 --- a/server/models/Suggestion.js +++ b/server/models/Suggestion.js @@ -1,10 +1,24 @@ -const mongoose = require('mongoose'); +const { tenantQuery } = require('../config/database'); -const suggestionSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - property: { type: mongoose.Schema.Types.ObjectId, ref: 'Property', required: true }, - content: { type: String, required: true, trim: true }, - createdAt: { type: Date, default: Date.now } -}); +class Suggestion { + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO suggestions (property_id, content) + VALUES ($1,$2) + RETURNING *`, + [data.propertyId, data.content] + ); + return res.rows[0]; + } -module.exports = mongoose.models.Suggestion || mongoose.model('Suggestion', suggestionSchema); + static async findByPropertyIds(schema, propertyIds) { + if (!propertyIds?.length) return []; + const res = await tenantQuery(schema, + `SELECT * FROM suggestions WHERE property_id = ANY($1::uuid[]) ORDER BY created_at DESC`, + [propertyIds] + ); + return res.rows; + } +} + +module.exports = Suggestion; diff --git a/server/models/Tenant.js b/server/models/Tenant.js index 0502c70..7f48246 100644 --- a/server/models/Tenant.js +++ b/server/models/Tenant.js @@ -1,20 +1,96 @@ -const mongoose = require('mongoose'); - -const tenantSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, - property: { type: mongoose.Schema.Types.ObjectId, ref: 'Property', required: true }, - unit: { type: String, required: true, trim: true }, - rentAmount: { type: Number, required: true }, - dueDate: { type: Number, default: 1 }, - status: { - type: String, - enum: ['active', 'defaulted', 'moved_out', 'pending'], - default: 'active' - }, - startDate: { type: Date, default: Date.now }, - leaseStart: { type: Date }, - leaseEnd: { type: Date } -}); - -module.exports = mongoose.models.Tenant || mongoose.model('Tenant', tenantSchema); +const { tenantQuery } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); + +const TENANT_UPDATE_FIELDS = [ + 'user_id', + 'property_id', + 'unit', + 'rent_amount', + 'due_date', + 'status', + 'start_date', + 'lease_start', + 'lease_end' +]; + +class Tenant { + static async find(schema, filter = {}) { + const conditions = []; + const values = []; + + if (filter.propertyId) { + values.push(filter.propertyId); + conditions.push(`property_id = $${values.length}`); + } + + if (filter.propertyIds?.length) { + values.push(filter.propertyIds); + conditions.push(`property_id = ANY($${values.length})`); + } + + if (filter.status) { + values.push(filter.status); + conditions.push(`status = $${values.length}`); + } + + if (filter.userId) { + values.push(filter.userId); + conditions.push(`user_id = $${values.length}`); + } + + if (filter.unit) { + values.push(filter.unit); + conditions.push(`unit = $${values.length}`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + const res = await tenantQuery(schema, `SELECT * FROM tenants ${where}`, values); + return res.rows; + } + + static async findOne(schema, filter = {}) { + const res = await this.find(schema, filter); + return res[0] || null; + } + + static async findById(schema, id) { + const res = await tenantQuery(schema, 'SELECT * FROM tenants WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async create(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO tenants + (user_id, property_id, unit, rent_amount, due_date, status, start_date, lease_start, lease_end) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [ + data.userId, + data.propertyId, + data.unit, + data.rentAmount, + data.dueDate || 1, + data.status || 'active', + data.startDate || new Date(), + data.leaseStart || null, + data.leaseEnd || null + ] + ); + return res.rows[0]; + } + + static async update(schema, id, updates) { + const normalized = normalizeUpdates(updates, TENANT_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return this.findById(schema, id); + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const res = await tenantQuery(schema, + `UPDATE tenants SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } +} + +module.exports = Tenant; diff --git a/server/models/Unit.js b/server/models/Unit.js index 45d3bc4..1789eb8 100644 --- a/server/models/Unit.js +++ b/server/models/Unit.js @@ -1,23 +1,88 @@ -const mongoose = require('mongoose'); - -const unitSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - property: { type: mongoose.Schema.Types.ObjectId, ref: 'Property', required: true }, - unitNumber: { type: String, required: true, trim: true }, - rentAmount: { type: Number, default: 0 }, - occupancyStatus: { - type: String, - enum: ['vacant', 'occupied', 'reserved', 'maintenance'], - default: 'vacant' - }, - tenantAssignment: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant' }, - meterReadings: { - water: { type: Number, default: 0 }, - electricity: { type: Number, default: 0 } - }, - isActive: { type: Boolean, default: true } -}, { timestamps: true }); - -unitSchema.index({ property: 1, unitNumber: 1 }, { unique: true }); - -module.exports = mongoose.models.Unit || mongoose.model('Unit', unitSchema); +const { tenantQuery } = require('../config/database'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); + +const UNIT_UPDATE_FIELDS = [ + 'property_id', + 'unit_number', + 'rent_amount', + 'occupancy_status', + 'tenant_assignment', + 'meter_readings', + 'is_active' +]; + +class Unit { + static async findByProperty(schema, propertyId, { includeInactive = false } = {}) { + const res = await tenantQuery(schema, + `SELECT * FROM units WHERE property_id = $1 ${includeInactive ? '' : 'AND is_active = TRUE'} + ORDER BY unit_number ASC`, + [propertyId] + ); + return res.rows; + } + + static async upsert(schema, data) { + const res = await tenantQuery(schema, + `INSERT INTO units + (property_id, unit_number, rent_amount, occupancy_status, tenant_assignment, meter_readings, is_active) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (property_id, unit_number) + DO UPDATE SET + rent_amount = EXCLUDED.rent_amount, + occupancy_status = EXCLUDED.occupancy_status, + tenant_assignment = EXCLUDED.tenant_assignment, + meter_readings = EXCLUDED.meter_readings, + is_active = EXCLUDED.is_active, + updated_at = NOW() + RETURNING *`, + [ + data.propertyId, + data.unitNumber, + data.rentAmount || 0, + data.occupancyStatus || 'vacant', + data.tenantAssignment || null, + data.meterReadings || {}, + data.isActive !== false + ] + ); + return res.rows[0]; + } + + static async findById(schema, id) { + const res = await tenantQuery(schema, 'SELECT * FROM units WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async update(schema, id, updates) { + const normalized = normalizeUpdates(updates, UNIT_UPDATE_FIELDS); + const fields = Object.keys(normalized); + if (!fields.length) return this.findById(schema, id); + const values = Object.values(normalized); + const setClause = buildSetClause(fields); + const res = await tenantQuery(schema, + `UPDATE units SET ${setClause}, updated_at = NOW() WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async deactivateMissing(schema, propertyId, activeUnitNumbers) { + await tenantQuery(schema, + `UPDATE units + SET is_active = FALSE, + occupancy_status = 'vacant', + tenant_assignment = NULL, + updated_at = NOW() + WHERE property_id = $1 + AND unit_number <> ALL($2::text[])`, + [propertyId, activeUnitNumbers] + ); + } + + static async countActive(schema) { + const res = await tenantQuery(schema, 'SELECT COUNT(*) FROM units WHERE is_active = TRUE'); + return Number(res.rows[0]?.count || 0); + } +} + +module.exports = Unit; diff --git a/server/models/User.js b/server/models/User.js index df1a3c7..b2a6a5e 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,26 +1,149 @@ -const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); +const { query } = require('../config/database'); const { ROLES } = require('../helpers/rbac'); +const { buildSetClause, normalizeUpdates } = require('../helpers/sql'); -const userSchema = new mongoose.Schema({ - organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, - name: { type: String, required: true, trim: true }, - email: { type: String, required: true, unique: true, lowercase: true, trim: true }, - password: { type: String, required: true }, - phoneNumber: { type: String, trim: true }, - role: { - type: String, - enum: Object.values(ROLES), - default: ROLES.TENANT - }, - status: { - type: String, - enum: ['pending', 'active', 'rejected', 'suspended'], - default: 'pending' - }, - interestedProperty: { type: mongoose.Schema.Types.ObjectId, ref: 'Property' }, - interestedUnit: { type: String, trim: true }, - isActive: { type: Boolean, default: true }, - lastLoginAt: { type: Date } -}, { timestamps: true }); - -module.exports = mongoose.models.User || mongoose.model('User', userSchema); +const SALT_ROUNDS = 12; +const USER_UPDATE_FIELDS = [ + 'organization_id', + 'name', + 'email', + 'password_hash', + 'phone_number', + 'role', + 'status', + 'interested_property_id', + 'interested_property_schema', + 'interested_unit', + 'is_active', + 'last_login_at', + 'requires_password_change' +]; + +class User { + static sanitize(user) { + if (!user) return null; + const { password_hash, ...rest } = user; + return rest; + } + + static async create({ + name, + email, + password, + phoneNumber = null, + role = ROLES.TENANT, + status = 'pending', + organizationId = null, + interestedPropertyId = null, + interestedPropertySchema = null, + interestedUnit = null, + isActive = true, + requiresPasswordChange = false + }, client = null) { + const hash = await bcrypt.hash(password, SALT_ROUNDS); + const executor = client || { query }; + const res = await executor.query( + `INSERT INTO public.users + (organization_id, name, email, password_hash, phone_number, role, status, + interested_property_id, interested_property_schema, interested_unit, is_active, requires_password_change) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + organizationId, + name, + email.toLowerCase(), + hash, + phoneNumber, + role, + status, + interestedPropertyId, + interestedPropertySchema, + interestedUnit, + isActive, + requiresPasswordChange + ] + ); + return res.rows[0]; + } + + static async findById(id) { + const res = await query('SELECT * FROM public.users WHERE id = $1', [id]); + return res.rows[0] || null; + } + + static async findByEmail(email) { + const normalizedEmail = String(email || '').trim().toLowerCase(); + if (!normalizedEmail) return null; + const res = await query('SELECT * FROM public.users WHERE email = $1', [normalizedEmail]); + return res.rows[0] || null; + } + + static async findByIds(ids = []) { + if (!ids.length) return []; + const res = await query('SELECT * FROM public.users WHERE id = ANY($1::uuid[])', [ids]); + return res.rows; + } + + static async update(id, updates, client = null) { + const normalized = { ...updates }; + if (normalized.email) { + normalized.email = normalized.email.toLowerCase(); + } + if (Object.prototype.hasOwnProperty.call(normalized, 'password')) { + normalized.password_hash = await bcrypt.hash(normalized.password, SALT_ROUNDS); + delete normalized.password; + } + const safeUpdates = normalizeUpdates(normalized, USER_UPDATE_FIELDS); + const fields = Object.keys(safeUpdates); + if (!fields.length) return this.findById(id); + const values = Object.values(safeUpdates); + const setClause = buildSetClause(fields); + const executor = client || { query }; + const res = await executor.query( + `UPDATE public.users SET ${setClause}, updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id, ...values] + ); + return res.rows[0] || null; + } + + static async verifyPassword(user, password) { + if (!user?.password_hash) return false; + return bcrypt.compare(password, user.password_hash); + } + + static async countByRole(role) { + const res = await query('SELECT COUNT(*) FROM public.users WHERE role = $1', [role]); + return Number(res.rows[0]?.count || 0); + } + + static async countByStatus(status) { + const res = await query('SELECT COUNT(*) FROM public.users WHERE status = $1', [status]); + return Number(res.rows[0]?.count || 0); + } + + static async findPending() { + const res = await query('SELECT * FROM public.users WHERE status = \'pending\' ORDER BY created_at DESC'); + return res.rows; + } + + static async findAll() { + const res = await query('SELECT * FROM public.users ORDER BY created_at DESC'); + return res.rows; + } + + static async findPendingByPropertyIds(propertyIds, schemaName) { + if (!propertyIds?.length) return []; + const res = await query( + `SELECT * FROM public.users + WHERE status = 'pending' + AND interested_property_id = ANY($1::uuid[]) + AND interested_property_schema = $2`, + [propertyIds, schemaName] + ); + return res.rows; + } +} + +module.exports = User; diff --git a/server/models/index.js b/server/models/index.js index ff8327d..e5fb354 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,5 +1,6 @@ module.exports = { Organization: require('./Organization'), + OrganizationMembership: require('./OrganizationMembership'), User: require('./User'), Property: require('./Property'), Unit: require('./Unit'), diff --git a/server/package-lock.json b/server/package-lock.json index 23aa025..6961c78 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,13 +11,18 @@ "dependencies": { "@google/generative-ai": "^0.2.1", "axios": "^1.13.6", + "bcryptjs": "^3.0.2", + "cloudinary": "^1.41.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "express-rate-limit": "^7.5.1", + "joi": "^17.13.3", "jsonwebtoken": "^9.0.3", - "mongoose": "^9.3.1", "multer": "^2.1.1", - "nodemailer": "^8.0.3" + "multer-storage-cloudinary": "^4.0.0", + "nodemailer": "^8.0.3", + "pg": "^8.16.0" }, "devDependencies": { "nodemon": "^3.1.0" @@ -32,30 +37,42 @@ "node": ">=18.0.0" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", - "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", - "license": "MIT", + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", "dependencies": { - "sparse-bitfield": "^3.0.3" + "@hapi/hoek": "^9.0.0" } }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "license": "MIT", + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", "dependencies": { - "@types/webidl-conversions": "*" + "@hapi/hoek": "^9.0.0" } }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -116,6 +133,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -179,15 +205,6 @@ "node": ">=8" } }, - "node_modules/bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -274,6 +291,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/cloudinary": { + "version": "1.41.3", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz", + "integrity": "sha512-4o84y+E7dbif3lMns+p3UW6w6hLHEifbX/7zBJvaih1E9QNMZITENQ14GPYJC4JmhygYXsuuBb9bRA3xWEoOfg==", + "license": "MIT", + "peer": true, + "dependencies": { + "cloudinary-core": "^2.13.0", + "core-js": "^3.30.1", + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/cloudinary-core": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/cloudinary-core/-/cloudinary-core-2.14.1.tgz", + "integrity": "sha512-57rgZSQD2cJsz1rga6M7jIDQuEAzkwvq63vTvs3/I8rNpGLyHMoKoIvBkNS0Guv5RZ9KDReJhI2LmElk4D9U1g==", + "license": "MIT", + "peerDependencies": { + "lodash": ">=4.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -341,6 +383,17 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -508,6 +561,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -546,6 +600,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -900,6 +969,19 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -943,14 +1025,12 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/kareem": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", - "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT", + "peer": true }, "node_modules/lodash.includes": { "version": "4.3.0", @@ -1012,12 +1092,6 @@ "node": ">= 0.8" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -1071,104 +1145,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mongodb": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", - "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.1.1", - "mongodb-connection-string-url": "^7.0.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongoose": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.3.1.tgz", - "integrity": "sha512-58DuQti+LlRS74/UfWN4F3wZsC0Yr1dgTWZ2Wd3/TuSvm6rIdyAjDWbx2xGyuBooqJYdAWotVv4mQgVdivh+3Q==", - "license": "MIT", - "dependencies": { - "kareem": "3.2.0", - "mongodb": "~7.1", - "mpath": "0.9.0", - "mquery": "6.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", - "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1194,6 +1170,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/multer-storage-cloudinary": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multer-storage-cloudinary/-/multer-storage-cloudinary-4.0.0.tgz", + "integrity": "sha512-25lm9R6o5dWrHLqLvygNX+kBOxprzpmZdnVKH4+r68WcfCt8XV6xfQaMuAg+kUE5Xmr8mJNA4gE0AcBj9FJyWA==", + "license": "MIT", + "peerDependencies": { + "cloudinary": "^1.21.0" + } + }, "node_modules/multer/node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1355,6 +1340,96 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1368,6 +1443,45 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1394,13 +1508,15 @@ "dev": true, "license": "MIT" }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.6.0", + "teleport": ">=0.2.0" } }, "node_modules/qs": { @@ -1646,12 +1762,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", - "license": "MIT" - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1665,13 +1775,13 @@ "node": ">=10" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" } }, "node_modules/statuses": { @@ -1745,18 +1855,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -1808,33 +1906,20 @@ "node": ">= 0.8" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/server/package.json b/server/package.json index 3fc0c43..6c9a540 100644 --- a/server/package.json +++ b/server/package.json @@ -8,6 +8,8 @@ "dev": "nodemon server.js", "seed:admin": "node scripts/seedSuperAdmin.js", "seed:starter-users": "node scripts/seedStarterUsers.js", + "seed:scenario": "node scripts/seedScenario.js", + "test:endpoints": "node tests/e2e-endpoints.js", "test": "echo \"Error: no test specified\" && exit 1" }, "devDependencies": { @@ -19,12 +21,17 @@ "dependencies": { "@google/generative-ai": "^0.2.1", "axios": "^1.13.6", + "bcryptjs": "^3.0.2", + "cloudinary": "^1.41.3", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", + "express-rate-limit": "^7.5.1", + "joi": "^17.13.3", "jsonwebtoken": "^9.0.3", - "mongoose": "^9.3.1", "multer": "^2.1.1", - "nodemailer": "^8.0.3" + "multer-storage-cloudinary": "^4.0.0", + "nodemailer": "^8.0.3", + "pg": "^8.16.0" } } diff --git a/server/routes/admin.js b/server/routes/admin.js index 612f2a6..828a8f3 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -6,6 +6,9 @@ const router = express.Router(); router.get('/summary', asyncHandler(adminController.getAdminSummary)); router.get('/organizations', asyncHandler(adminController.getOrganizations)); +router.put('/organizations/:id/billing', asyncHandler(adminController.updateOrganizationBilling)); router.get('/logs', asyncHandler(adminController.getAuditLogs)); +router.get('/users/pending', asyncHandler(adminController.getPendingUsers)); +router.post('/users/:userId/approve', asyncHandler(adminController.approveUser)); module.exports = router; diff --git a/server/routes/auth.js b/server/routes/auth.js index b169943..0723766 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -3,10 +3,22 @@ const router = express.Router(); const asyncHandler = require('../helpers/asyncHandler'); const authController = require('../controllers/authController'); const authMiddleware = require('../middleware/auth'); +const { validate, schemas } = require('../middleware/validation'); +const { tenantResolver } = require('../middleware/tenantResolver'); -router.get('/properties/available', asyncHandler(authController.getAvailableProperties)); +const { optionalAuth } = require('../middleware/auth'); +const authorize = require('../middleware/authorize'); +const { ROLES } = require('../helpers/rbac'); + +router.get('/properties/available', optionalAuth, asyncHandler(authController.getAvailableProperties)); router.get('/me', authMiddleware, asyncHandler(authController.getCurrentUser)); -router.post('/register', asyncHandler(authController.register)); -router.post('/login', asyncHandler(authController.login)); +router.post('/register', validate(schemas.register), asyncHandler(authController.register)); +router.post('/inquiry', validate(schemas.inquiry), asyncHandler(authController.submitInquiry)); +router.post('/login', validate(schemas.login), asyncHandler(authController.login)); +router.post('/change-password', authMiddleware, asyncHandler(authController.changePassword)); + +// Staff management +router.get('/staff', authMiddleware, tenantResolver, authorize(ROLES.LANDLORD, ROLES.SUPER_ADMIN), asyncHandler(authController.getOrganizationStaff)); +router.post('/staff', authMiddleware, tenantResolver, authorize(ROLES.LANDLORD, ROLES.SUPER_ADMIN), validate(schemas.register), asyncHandler(authController.addOrganizationStaff)); module.exports = router; diff --git a/server/routes/billing.js b/server/routes/billing.js new file mode 100644 index 0000000..36f7b6e --- /dev/null +++ b/server/routes/billing.js @@ -0,0 +1,9 @@ +const express = require('express'); +const router = express.Router(); +const asyncHandler = require('../helpers/asyncHandler'); +const billingController = require('../controllers/billingController'); + +router.get('/my-billing', asyncHandler(billingController.getMyBilling)); +router.post('/pay-subscription', asyncHandler(billingController.initializeSubscriptionPayment)); + +module.exports = router; diff --git a/server/routes/index.js b/server/routes/index.js index 1afae18..e643422 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -1,11 +1,14 @@ const express = require('express'); const asyncHandler = require('../helpers/asyncHandler'); const authMiddleware = require('../middleware/auth'); +const { apiRateLimiter } = require('../middleware/rateLimiter'); const authorize = require('../middleware/authorize'); +const { tenantResolver } = require('../middleware/tenantResolver'); const paymentController = require('../controllers/paymentController'); const { ROLES } = require('../helpers/rbac'); const adminRoutes = require('./admin'); const authRoutes = require('./auth'); +const billingRoutes = require('./billing'); const leaseRoutes = require('./leases'); const messageRoutes = require('./messages'); const notificationRoutes = require('./notifications'); @@ -18,9 +21,16 @@ const unitRoutes = require('./units'); const router = express.Router(); +const webhookController = require('../controllers/webhookController'); + router.use('/auth', authRoutes); +router.post('/webhooks/paystack', asyncHandler(webhookController.handlePaystackWebhook)); router.post('/payments/callback', asyncHandler(paymentController.handleMpesaCallback)); +router.use(apiRateLimiter); router.use(authMiddleware); +router.use('/admin', authorize(ROLES.SUPER_ADMIN), adminRoutes); +router.use('/billing', tenantResolver, billingRoutes); +router.use(tenantResolver); router.use(propertyRoutes); router.use(unitRoutes); router.use(reminderRoutes); @@ -30,6 +40,5 @@ router.use(paymentRoutes); router.use(leaseRoutes); router.use(suggestionRoutes); router.use(notificationRoutes); -router.use('/admin', authorize(ROLES.SUPER_ADMIN), adminRoutes); module.exports = router; diff --git a/server/routes/leases.js b/server/routes/leases.js index 4795667..4e5442b 100644 --- a/server/routes/leases.js +++ b/server/routes/leases.js @@ -8,5 +8,6 @@ router.post('/leases/upload', leaseUpload.single('lease'), asyncHandler(leaseCon router.get('/leases/tenant', asyncHandler(leaseController.getTenantLease)); router.get('/leases/landlord', asyncHandler(leaseController.getLandlordLeases)); router.get('/leases/view/:id', asyncHandler(leaseController.viewLease)); +router.delete('/leases/:id', asyncHandler(leaseController.deleteLease)); module.exports = router; diff --git a/server/routes/messages.js b/server/routes/messages.js index 1eee815..ab5322f 100644 --- a/server/routes/messages.js +++ b/server/routes/messages.js @@ -2,8 +2,10 @@ const express = require('express'); const router = express.Router(); const asyncHandler = require('../helpers/asyncHandler'); const messageController = require('../controllers/messageController'); +const { validate, schemas } = require('../middleware/validation'); router.get('/messages', asyncHandler(messageController.getMessages)); -router.post('/messages', asyncHandler(messageController.createMessage)); +router.post('/messages', validate(schemas.message), asyncHandler(messageController.createMessage)); +router.post('/messages/toggle-global', asyncHandler(messageController.toggleGlobalChat)); module.exports = router; diff --git a/server/routes/payments.js b/server/routes/payments.js index c5b031d..89e3357 100644 --- a/server/routes/payments.js +++ b/server/routes/payments.js @@ -2,8 +2,16 @@ const express = require('express'); const router = express.Router(); const asyncHandler = require('../helpers/asyncHandler'); const paymentController = require('../controllers/paymentController'); +const { validate, schemas } = require('../middleware/validation'); router.get('/payments', asyncHandler(paymentController.getPayments)); -router.post('/payments/stkpush', asyncHandler(paymentController.initiatePaymentStkPush)); +router.post('/payments/stkpush', validate(schemas.paymentStk), asyncHandler(paymentController.initiatePaymentStkPush)); + +// Settings +router.get('/payments/settings', asyncHandler(paymentController.getPaymentSettings)); +router.put('/payments/settings', asyncHandler(paymentController.updatePaymentSettings)); + +// Public/Tenant facing methods +router.get('/payments/methods', asyncHandler(paymentController.getLandlordPaymentMethods)); module.exports = router; diff --git a/server/routes/properties.js b/server/routes/properties.js index e63ef8f..6a527eb 100644 --- a/server/routes/properties.js +++ b/server/routes/properties.js @@ -2,14 +2,16 @@ const express = require('express'); const router = express.Router(); const asyncHandler = require('../helpers/asyncHandler'); const propertyController = require('../controllers/propertyController'); +const { validate, schemas } = require('../middleware/validation'); router.get('/properties', asyncHandler(propertyController.getProperties)); -router.post('/properties', asyncHandler(propertyController.createProperty)); -router.put('/properties/:id', asyncHandler(propertyController.updateProperty)); +router.post('/properties', validate(schemas.createProperty), asyncHandler(propertyController.createProperty)); +router.put('/properties/:id', validate(schemas.updateProperty), asyncHandler(propertyController.updateProperty)); +router.delete('/properties/:id', asyncHandler(propertyController.deleteProperty)); router.get('/properties/:id/tenants', asyncHandler(propertyController.getPropertyTenants)); -router.post('/tenants', asyncHandler(propertyController.createTenant)); +router.post('/tenants', validate(schemas.createTenant), asyncHandler(propertyController.createTenant)); router.get('/pending-registrations', asyncHandler(propertyController.getPendingRegistrations)); -router.post('/approve-registration/:userId', asyncHandler(propertyController.approveRegistration)); +router.post('/approve-registration/:userId', validate(schemas.approveRegistration), asyncHandler(propertyController.approveRegistration)); router.post('/reject-registration/:userId', asyncHandler(propertyController.rejectRegistration)); module.exports = router; diff --git a/server/routes/reminders.js b/server/routes/reminders.js index edc4e44..16e7f54 100644 --- a/server/routes/reminders.js +++ b/server/routes/reminders.js @@ -2,7 +2,14 @@ const express = require('express'); const router = express.Router(); const asyncHandler = require('../helpers/asyncHandler'); const reminderController = require('../controllers/reminderController'); +const authorize = require('../middleware/authorize'); +const { ROLES } = require('../helpers/rbac'); -router.post('/reminders/generate', asyncHandler(reminderController.generateTenantReminder)); +router.use('/reminders', authorize(ROLES.LANDLORD, ROLES.PROPERTY_MANAGER, ROLES.SUPER_ADMIN)); + +router.post('/reminders/toggle', asyncHandler(reminderController.toggleAutoReminders)); +router.post('/reminders/settings', asyncHandler(reminderController.updateReminderSettings)); +router.post('/reminders/manual', asyncHandler(reminderController.triggerManualReminder)); +router.post('/reminders/trigger-all', asyncHandler(reminderController.triggerAllReminders)); module.exports = router; diff --git a/server/routes/repairs.js b/server/routes/repairs.js index ec95608..4e4c347 100644 --- a/server/routes/repairs.js +++ b/server/routes/repairs.js @@ -3,9 +3,11 @@ const router = express.Router(); const asyncHandler = require('../helpers/asyncHandler'); const repairController = require('../controllers/repairController'); const { repairUpload } = require('../helpers/upload'); +const { validate, schemas } = require('../middleware/validation'); -router.post('/repairs', repairUpload.single('image'), asyncHandler(repairController.createRepairRequest)); +router.post('/repairs', repairUpload.single('image'), validate(schemas.repairRequest), asyncHandler(repairController.createRepairRequest)); router.get('/repairs', asyncHandler(repairController.getRepairRequests)); -router.put('/repairs/:id', asyncHandler(repairController.updateRepairRequest)); +router.put('/repairs/:id', validate(schemas.repairUpdate), asyncHandler(repairController.updateRepairRequest)); +router.delete('/repairs/:id', asyncHandler(repairController.deleteRepairRequest)); module.exports = router; diff --git a/server/routes/suggestions.js b/server/routes/suggestions.js index d8132ad..5292091 100644 --- a/server/routes/suggestions.js +++ b/server/routes/suggestions.js @@ -2,8 +2,9 @@ const express = require('express'); const router = express.Router(); const asyncHandler = require('../helpers/asyncHandler'); const suggestionController = require('../controllers/suggestionController'); +const { validate, schemas } = require('../middleware/validation'); -router.post('/suggestions', asyncHandler(suggestionController.createSuggestion)); +router.post('/suggestions', validate(schemas.suggestion), asyncHandler(suggestionController.createSuggestion)); router.get('/suggestions', asyncHandler(suggestionController.getSuggestions)); module.exports = router; diff --git a/server/routes/units.js b/server/routes/units.js index 0df068f..d62c758 100644 --- a/server/routes/units.js +++ b/server/routes/units.js @@ -1,11 +1,12 @@ const express = require('express'); const asyncHandler = require('../helpers/asyncHandler'); const unitController = require('../controllers/unitController'); +const { validate, schemas } = require('../middleware/validation'); const router = express.Router(); router.get('/properties/:propertyId/units', asyncHandler(unitController.getUnitsByProperty)); -router.post('/units', asyncHandler(unitController.createUnit)); -router.put('/units/:id', asyncHandler(unitController.updateUnit)); +router.post('/units', validate(schemas.createUnit), asyncHandler(unitController.createUnit)); +router.put('/units/:id', validate(schemas.updateUnit), asyncHandler(unitController.updateUnit)); module.exports = router; diff --git a/server/scripts/seedScenario.js b/server/scripts/seedScenario.js new file mode 100644 index 0000000..7cda5a9 --- /dev/null +++ b/server/scripts/seedScenario.js @@ -0,0 +1,70 @@ +const { User, Organization, Property, Unit, Tenant, Subscription } = require('../models'); +const { createTenantSchema } = require('../services/tenantService'); +const { transaction } = require('../config/database'); +const { ROLES } = require('../helpers/rbac'); + +async function seed() { + console.log('--- Starting Demo Seed ---'); + + try { + // 1. Create a Landlord + const landlordData = { + name: 'Test Landlord', + email: 'landlord@test.com', + password: 'password123', + role: ROLES.LANDLORD, + status: 'active' + }; + + let org; + await transaction(async (client) => { + org = await Organization.create({ + name: 'Prime Portfolios', + status: 'active', + pricePerUnit: 500, + billingCycleMonths: 1 + }, client); + + const user = await User.create({ + ...landlordData, + organizationId: org.id + }, client); + + await Organization.update(org.id, { owner_id: user.id }, client); + await createTenantSchema(org.schema_name); + + await Subscription.create({ + organizationId: org.id, + status: 'active', + nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + }, client); + }); + + console.log('Landlord and Org created:', org.schema_name); + + // 2. Add a Property and Units to that Org + const property = await Property.create(org.schema_name, { + name: 'Sunset Heights', + address: '123 Tech Lane', + units: ['A1', 'A2', 'B1'] + }); + + for (const u of ['A1', 'A2', 'B1']) { + await Unit.upsert(org.schema_name, { + propertyId: property.id, + unitNumber: u, + occupancyStatus: 'vacant' + }); + } + + console.log('Property and Units created.'); + console.log('--- Seed Complete ---'); + console.log('Login with: landlord@test.com / password123'); + process.exit(0); + } catch (err) { + console.error('Seed failed:', err); + process.exit(1); + } +} + +seed(); diff --git a/server/scripts/seedStarterUsers.js b/server/scripts/seedStarterUsers.js index efc87a3..3dba67c 100644 --- a/server/scripts/seedStarterUsers.js +++ b/server/scripts/seedStarterUsers.js @@ -1,17 +1,19 @@ const fs = require('fs'); const path = require('path'); -const { connectDatabase, mongoose } = require('../config/database'); -const { mongodbUri, uploadsRoot } = require('../config/env'); +const { connectDatabase } = require('../config/database'); +const { runPublicMigrations } = require('../database'); +const { uploadsRoot } = require('../config/env'); const { Lease, - Notification, Payment, Property, Tenant, Unit, - User + User, + Organization } = require('../models'); const { ensureOrganizationForUser } = require('../helpers/organization'); +const { createNotification } = require('../helpers/notifications'); const { ROLES } = require('../helpers/rbac'); const tenantEmail = (process.env.TENANT_EMAIL || 'kamau1@gmail.com').trim().toLowerCase(); @@ -27,58 +29,39 @@ const rentAmount = Number(process.env.STARTER_RENT_AMOUNT || 25000); const buildLeaseFileName = (email) => `${email.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}-lease.txt`; -const upsertUser = async ({ email, name, organization = null, role, status }) => { - let user = await User.findOne({ email }); +const upsertUser = async ({ email, name, organizationId = null, role, status }) => { + let user = await User.findByEmail(email); if (!user) { - user = new User({ + user = await User.create({ name, email, password, role, status, - organization + organizationId }); } else { - user.name = name; - user.password = password; - user.role = role; - user.status = status; - user.isActive = true; - user.organization = organization; + user = await User.update(user.id, { + name, + password, + role, + status, + is_active: true, + organization_id: organizationId + }); } - await user.save(); return user; }; -const upsertNotification = async ({ organization, user, title, message, type = 'system_notice' }) => { - const existing = await Notification.findOne({ user, title }); - - if (existing) { - existing.message = message; - existing.organization = organization; - existing.type = type; - existing.isRead = false; - await existing.save(); - return existing; - } - - return Notification.create({ - organization, - user, - title, - message, - type - }); -}; - const seedStarterUsers = async () => { if (!tenantEmail || !landlordEmail || !password) { throw new Error('TENANT_EMAIL, LANDLORD_EMAIL, and USER_PASSWORD must be set.'); } - await connectDatabase(mongodbUri); + await connectDatabase(); + await runPublicMigrations(); let landlord = await upsertUser({ email: landlordEmail, @@ -88,101 +71,80 @@ const seedStarterUsers = async () => { }); const organizationId = await ensureOrganizationForUser(landlord, `${landlordName}'s Portfolio`); - landlord = await User.findById(landlord._id); - - let property = await Property.findOne({ - organization: organizationId, - name: propertyName - }); + const organization = await Organization.findById(organizationId); + const schema = organization.schema_name; + landlord = await User.findById(landlord.id); + let property = await Property.findByName(schema, propertyName); if (!property) { - property = new Property({ - organization: organizationId, + property = await Property.create(schema, { name: propertyName, address: propertyAddress, description: 'Starter property for landlord and tenant testing.', type: 'apartment', - landlord: landlord._id, + landlordId: landlord.id, units: [occupiedUnitNumber, vacantUnitNumber] }); } else { - property.organization = organizationId; - property.address = propertyAddress; - property.description = 'Starter property for landlord and tenant testing.'; - property.type = 'apartment'; - property.landlord = landlord._id; - property.units = [occupiedUnitNumber, vacantUnitNumber]; + property = await Property.update(schema, property.id, { + address: propertyAddress, + description: 'Starter property for landlord and tenant testing.', + type: 'apartment', + landlord_id: landlord.id, + units: [occupiedUnitNumber, vacantUnitNumber] + }); } - await property.save(); - let tenantUser = await upsertUser({ email: tenantEmail, name: tenantName, - organization: organizationId, + organizationId, role: ROLES.TENANT, status: 'active' }); - tenantUser.interestedProperty = property._id; - tenantUser.interestedUnit = occupiedUnitNumber; - await tenantUser.save(); - - let tenant = await Tenant.findOne({ user: tenantUser._id }); + tenantUser = await User.update(tenantUser.id, { + interested_property_id: property.id, + interested_property_schema: schema, + interested_unit: occupiedUnitNumber + }); + let tenant = await Tenant.findOne(schema, { userId: tenantUser.id }); if (!tenant) { - tenant = new Tenant({ - organization: organizationId, - user: tenantUser._id, - property: property._id, + tenant = await Tenant.create(schema, { + userId: tenantUser.id, + propertyId: property.id, unit: occupiedUnitNumber, rentAmount, dueDate: 5, status: 'active' }); } else { - tenant.organization = organizationId; - tenant.property = property._id; - tenant.unit = occupiedUnitNumber; - tenant.rentAmount = rentAmount; - tenant.dueDate = 5; - tenant.status = 'active'; + tenant = await Tenant.update(schema, tenant.id, { + property_id: property.id, + unit: occupiedUnitNumber, + rent_amount: rentAmount, + due_date: 5, + status: 'active' + }); } - await tenant.save(); - - await Unit.findOneAndUpdate({ - property: property._id, - unitNumber: occupiedUnitNumber - }, { - organization: organizationId, - property: property._id, + await Unit.upsert(schema, { + propertyId: property.id, unitNumber: occupiedUnitNumber, rentAmount, occupancyStatus: 'occupied', - tenantAssignment: tenant._id, + tenantAssignment: tenant.id, isActive: true - }, { - upsert: true, - returnDocument: 'after', - setDefaultsOnInsert: true }); - await Unit.findOneAndUpdate({ - property: property._id, - unitNumber: vacantUnitNumber - }, { - organization: organizationId, - property: property._id, + await Unit.upsert(schema, { + propertyId: property.id, unitNumber: vacantUnitNumber, rentAmount: rentAmount + 3000, occupancyStatus: 'vacant', tenantAssignment: null, isActive: true - }, { - upsert: true, - returnDocument: 'after', - setDefaultsOnInsert: true }); fs.mkdirSync(path.join(uploadsRoot, 'leases'), { recursive: true }); @@ -204,17 +166,11 @@ const seedStarterUsers = async () => { 'utf8' ); - let lease = await Lease.findOne({ - tenant: tenantUser._id, - property: property._id, - unit: occupiedUnitNumber - }); - + let lease = await Lease.findByTenantAndUnit(schema, tenantUser.id, property.id, occupiedUnitNumber); if (!lease) { - lease = new Lease({ - organization: organizationId, - tenant: tenantUser._id, - property: property._id, + lease = await Lease.create(schema, { + tenantId: tenantUser.id, + propertyId: property.id, unit: occupiedUnitNumber, filePath: leaseRelativePath, fileName: leaseFileName, @@ -223,24 +179,13 @@ const seedStarterUsers = async () => { depositAmount: rentAmount, penaltyTerms: 'Late payment attracts a 5% penalty after 5 days.' }); - } else { - lease.organization = organizationId; - lease.filePath = leaseRelativePath; - lease.fileName = leaseFileName; - lease.startDate = new Date('2026-01-01'); - lease.endDate = new Date('2026-12-31'); - lease.depositAmount = rentAmount; - lease.penaltyTerms = 'Late payment attracts a 5% penalty after 5 days.'; } - await lease.save(); - - let payment = await Payment.findOne({ tenant: tenant._id }).sort({ createdAt: -1 }); - + const existingPayments = await Payment.findByTenantId(schema, tenant.id); + let payment = existingPayments[0]; if (!payment) { - payment = new Payment({ - organization: organizationId, - tenant: tenant._id, + payment = await Payment.create(schema, { + tenantId: tenant.id, amount: rentAmount, currency: 'KSh', method: 'mpesa', @@ -248,26 +193,25 @@ const seedStarterUsers = async () => { dueDate: new Date('2026-04-05') }); } else { - payment.organization = organizationId; - payment.amount = rentAmount; - payment.currency = 'KSh'; - payment.method = 'mpesa'; - payment.status = 'pending'; - payment.dueDate = new Date('2026-04-05'); + await Payment.update(schema, payment.id, { + amount: rentAmount, + currency: 'KSh', + method: 'mpesa', + status: 'pending', + due_date: new Date('2026-04-05') + }); } - await payment.save(); - - await upsertNotification({ - organization: organizationId, - user: tenantUser._id, + await createNotification({ + schema, + userId: tenantUser.id, title: 'Welcome to Apex', message: `${propertyName} unit ${occupiedUnitNumber} is ready on your dashboard.` }); - await upsertNotification({ - organization: organizationId, - user: landlord._id, + await createNotification({ + schema, + userId: landlord.id, title: 'Starter data ready', message: `${tenantName} has been attached to ${propertyName} unit ${occupiedUnitNumber}.` }); @@ -298,6 +242,3 @@ seedStarterUsers() console.error(error); process.exitCode = 1; }) - .finally(async () => { - await mongoose.connection.close(); - }); diff --git a/server/scripts/seedSuperAdmin.js b/server/scripts/seedSuperAdmin.js index 6f78721..5aef897 100644 --- a/server/scripts/seedSuperAdmin.js +++ b/server/scripts/seedSuperAdmin.js @@ -1,11 +1,10 @@ -const { connectDatabase, mongoose } = require('../config/database'); -const { mongodbUri } = require('../config/env'); +const { connectDatabase, pool } = require('../config/database'); +const { runPublicMigrations } = require('../database'); const { User } = require('../models'); -const { ensureOrganizationForUser } = require('../helpers/organization'); const { ROLES } = require('../helpers/rbac'); const adminName = process.env.SUPER_ADMIN_NAME || 'Apex Super Admin'; -const adminEmail = (process.env.SUPER_ADMIN_EMAIL || 'admin@apex.local').trim().toLowerCase(); +const adminEmail = (process.env.SUPER_ADMIN_EMAIL || 'admin.e2e@example.com').trim().toLowerCase(); const adminPassword = process.env.SUPER_ADMIN_PASSWORD || 'Admin123!'; const seedSuperAdmin = async () => { @@ -13,13 +12,14 @@ const seedSuperAdmin = async () => { throw new Error('SUPER_ADMIN_EMAIL and SUPER_ADMIN_PASSWORD must be set.'); } - await connectDatabase(mongodbUri); + await connectDatabase(); + await runPublicMigrations(); - let user = await User.findOne({ email: adminEmail }); + let user = await User.findByEmail(adminEmail); const existed = Boolean(user); if (!user) { - user = new User({ + user = await User.create({ name: adminName, email: adminEmail, password: adminPassword, @@ -27,23 +27,21 @@ const seedSuperAdmin = async () => { status: 'active' }); } else { - user.name = adminName; - user.password = adminPassword; - user.role = ROLES.SUPER_ADMIN; - user.status = 'active'; - user.isActive = true; + user = await User.update(user.id, { + name: adminName, + password: adminPassword, + role: ROLES.SUPER_ADMIN, + status: 'active', + is_active: true, + organization_id: null + }); } - - await user.save(); - const organizationId = await ensureOrganizationForUser(user, 'Apex Platform Admin'); - console.log(JSON.stringify({ message: existed ? 'Super admin updated' : 'Super admin created', email: user.email, password: adminPassword, - role: user.role, - organizationId: organizationId.toString() - }, null, 2)); + role: user.role + }, null, 2)); }; seedSuperAdmin() @@ -53,5 +51,5 @@ seedSuperAdmin() process.exitCode = 1; }) .finally(async () => { - await mongoose.connection.close(); + await pool.end(); }); diff --git a/server/server.js b/server/server.js index 4303d3f..f020688 100644 --- a/server/server.js +++ b/server/server.js @@ -1,17 +1,22 @@ const app = require('./app'); const { connectDatabase } = require('./config/database'); -const { mongodbUri, port } = require('./config/env'); +const { runPublicMigrations } = require('./database'); +const { port } = require('./config/env'); +const { startScheduler } = require('./services/scheduler'); const startServer = async () => { try { - await connectDatabase(mongodbUri); - console.log('Connected to MongoDB'); + await connectDatabase(); + await runPublicMigrations(); + console.log('Connected to Postgres'); + + startScheduler(); app.listen(port, () => { console.log(`Server running on port ${port}`); }); } catch (err) { - console.error('Could not connect to MongoDB', err); + console.error('Could not connect to Postgres', err); process.exit(1); } }; diff --git a/server/services/billingService.js b/server/services/billingService.js new file mode 100644 index 0000000..4cc127c --- /dev/null +++ b/server/services/billingService.js @@ -0,0 +1,76 @@ +const axios = require('axios'); +const { Organization, Subscription, Unit } = require('../models'); +const { tenantQuery } = require('../config/database'); + +const PAYSTACK_SECRET_KEY = process.env.PAYSTACK_SECRET_KEY; + +const countOccupiedUnits = async (schemaName) => { + const res = await tenantQuery(schemaName, + "SELECT COUNT(*) FROM units WHERE occupancy_status = 'occupied' AND is_active = TRUE" + ); + return Number(res.rows[0]?.count || 0); +}; + +const generatePaystackLink = async (email, amount, metadata = {}) => { + try { + const response = await axios.post('https://api.paystack.co/transaction/initialize', { + email, + amount: amount * 100, // Paystack works in kobo/cents + metadata, + callback_url: `${process.env.FRONTEND_URL}/billing/callback` + }, { + headers: { + Authorization: `Bearer ${PAYSTACK_SECRET_KEY}`, + 'Content-Type': 'application/json' + } + }); + return response.data.data.authorization_url; + } catch (error) { + console.error('Paystack Init Error:', error.response?.data || error.message); + throw new Error('Failed to initialize Paystack payment'); + } +}; + +const checkAndGenerateBills = async () => { + console.log('--- Starting Subscription Billing Check ---'); + const organizations = await Organization.findAll(); + const today = new Date(); + + for (const org of organizations) { + if (!org.schema_name) continue; + + const sub = await Subscription.findByOrganization(org.id); + if (!sub) continue; + + // Check if next_billing_date is reached + if (sub.next_billing_date && new Date(sub.next_billing_date) <= today) { + const occupiedUnits = await countOccupiedUnits(org.schema_name); + const billAmount = occupiedUnits * Number(org.price_per_unit); + + console.log(`Billing Org: ${org.name} | Units: ${occupiedUnits} | Amount: ${billAmount}`); + + // Here we would ideally send an email with the Paystack link + // For now, we update the organization status to 'past_due' if amount > 0 + if (billAmount > 0) { + await Organization.update(org.id, { status: 'suspended' }); // Lock them out + } + + // Update subscription for next cycle + const nextDate = new Date(); + nextDate.setMonth(nextDate.getMonth() + (org.billing_cycle_months || 1)); + + await Subscription.update(sub.id, { + last_billed_at: today, + next_billing_date: nextDate, + billable_units_at_last_bill: occupiedUnits + }); + } + } + console.log('--- Finished Subscription Billing Check ---'); +}; + +module.exports = { + countOccupiedUnits, + generatePaystackLink, + checkAndGenerateBills +}; diff --git a/server/services/emailService.js b/server/services/emailService.js index 396e06c..8ee4081 100644 --- a/server/services/emailService.js +++ b/server/services/emailService.js @@ -53,4 +53,29 @@ const notifyLandlord = async (landlordEmail, tenantName, amount, unit) => { } }; -module.exports = { sendPaymentConfirmation, notifyLandlord }; +const sendAccountCreatedEmail = async (toEmail, userName, password, role) => { + const mailOptions = { + from: `"Apex Agencies" <${process.env.EMAIL_USER}>`, + to: toEmail, + subject: 'Welcome to Apex Agencies - Account Created', + html: ` +

Welcome ${userName},

+

Your account on Apex Agencies has been successfully created as a ${role.replace(/_/g, ' ')}.

+

Below are your login credentials:

+

Email: ${toEmail}

+

Temporary Password: ${password}

+
+

Please Note: You will be required to change this password upon your first login for security purposes.

+

Thank you,

+

The Apex Agencies Team

+ `, + }; + + try { + await transporter.sendMail(mailOptions); + } catch (err) { + console.error("Account Created Email Error:", err); + } +}; + +module.exports = { sendPaymentConfirmation, notifyLandlord, sendAccountCreatedEmail }; diff --git a/server/services/mpesaService.js b/server/services/mpesaService.js index 44e0155..6663014 100644 --- a/server/services/mpesaService.js +++ b/server/services/mpesaService.js @@ -9,8 +9,11 @@ const shortCode = process.env.MPESA_SHORTCODE; const passkey = process.env.MPESA_PASSKEY; const callbackUrl = process.env.MPESA_CALLBACK_URL; -const getAccessToken = async () => { - const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64'); +const getAccessToken = async (credentials = {}) => { + const cKey = credentials.consumerKey || process.env.MPESA_CONSUMER_KEY; + const cSecret = credentials.consumerSecret || process.env.MPESA_CONSUMER_SECRET; + + const auth = Buffer.from(`${cKey}:${cSecret}`).toString('base64'); try { const res = await axios.get('https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials', { headers: { Authorization: `Basic ${auth}` } @@ -22,21 +25,31 @@ const getAccessToken = async () => { } }; -const initiateSTKPush = async (phoneNumber, amount, accountReference) => { - const token = await getAccessToken(); +const initiateSTKPush = async (phoneNumber, amount, accountReference, config = {}) => { + const credentials = { + consumerKey: config.mpesa_consumer_key, + consumerSecret: config.mpesa_consumer_secret + }; + + const token = await getAccessToken(credentials); const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14); - const password = Buffer.from(`${shortCode}${passkey}${timestamp}`).toString('base64'); + + const sCode = config.mpesa_shortcode || process.env.MPESA_SHORTCODE; + const pKey = config.mpesa_passkey || process.env.MPESA_PASSKEY; + const cbUrl = config.mpesa_callback_url || process.env.MPESA_CALLBACK_URL; + + const password = Buffer.from(`${sCode}${pKey}${timestamp}`).toString('base64'); const data = { - BusinessShortCode: shortCode, + BusinessShortCode: sCode, Password: password, Timestamp: timestamp, TransactionType: "CustomerPayBillOnline", Amount: amount, PartyA: phoneNumber, - PartyB: shortCode, + PartyB: sCode, PhoneNumber: phoneNumber, - CallBackURL: callbackUrl, + CallBackURL: cbUrl, AccountReference: accountReference, TransactionDesc: "Rent Payment" }; diff --git a/server/services/reminderService.js b/server/services/reminderService.js new file mode 100644 index 0000000..7e453d5 --- /dev/null +++ b/server/services/reminderService.js @@ -0,0 +1,114 @@ +const { Organization, User, Property, Tenant, Invoice, Notification } = require('../models'); +const { generateReminder } = require('./aiService'); +const { createNotification } = require('../helpers/notifications'); +const { sendAccountCreatedEmail } = require('./emailService'); // I might need a more generic email sender +const nodemailer = require('nodemailer'); + +const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: 587, + secure: false, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, +}); + +const sendEmailReminder = async (toEmail, subject, text) => { + const mailOptions = { + from: `"Apex Agencies" <${process.env.EMAIL_USER}>`, + to: toEmail, + subject: subject, + html: `

${text.replace(/\n/g, '
')}

`, + }; + + try { + await transporter.sendMail(mailOptions); + } catch (err) { + console.error("Email Reminder Error:", err); + } +}; + +const processTenantReminder = async (schema, tenant, organization) => { + const today = new Date(); + const todayDay = today.getDate(); + const settings = organization.reminder_settings || { before_days: 3, after_days: 3 }; + + const property = await Property.findById(schema, tenant.property_id); + const user = await User.findById(tenant.user_id); + + if (!property || !user) return; + + let shouldSend = false; + let type = ''; + + // 1. Check if rent is due in X days + if (tenant.due_date - settings.before_days === todayDay) { + shouldSend = true; + type = 'Upcoming Rent'; + } + + // 2. Check if rent is due TODAY + if (tenant.due_date === todayDay) { + shouldSend = true; + type = 'Rent Due Today'; + } + + // 3. Check if rent is overdue by X days (and unpaid) + if (tenant.due_date + settings.after_days === todayDay) { + // Check for unpaid invoices for this month + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const invoices = await Invoice.find(schema, { + tenantId: tenant.id, + status: 'sent' // assuming 'sent' means unpaid + }); + + if (invoices.length > 0) { + shouldSend = true; + type = 'Overdue Rent'; + } + } + + if (shouldSend) { + const reminderText = await generateReminder(user.name, property.name || property.address, tenant.rent_amount, tenant.due_date); + + // Send In-App Notification + await createNotification({ + schema, + userId: user.id, + title: `${type} Reminder`, + message: reminderText, + type: 'rent_due' + }); + + // Send Email + await sendEmailReminder(user.email, `${type} - ${property.name || 'Apex Agencies'}`, reminderText); + + return true; + } + return false; +}; + +const runAutoReminders = async () => { + console.log('--- Starting Auto-Reminders Job ---'); + const organizations = await Organization.findAll(); + + for (const org of organizations) { + if (!org.auto_reminders_enabled || !org.schema_name) continue; + + try { + const tenants = await Tenant.find(org.schema_name, { status: 'active' }); + for (const tenant of tenants) { + await processTenantReminder(org.schema_name, tenant, org); + } + } catch (error) { + console.error(`Error processing reminders for ${org.name}:`, error.message); + } + } + console.log('--- Finished Auto-Reminders Job ---'); +}; + +module.exports = { + processTenantReminder, + runAutoReminders +}; diff --git a/server/services/scheduler.js b/server/services/scheduler.js new file mode 100644 index 0000000..8114d74 --- /dev/null +++ b/server/services/scheduler.js @@ -0,0 +1,22 @@ +const { runAutoReminders } = require('./reminderService'); +const { checkAndGenerateBills } = require('./billingService'); + +const startScheduler = () => { + console.log('Scheduler initialized.'); + + // Run reminders and billing check once every 24 hours + const interval = 24 * 60 * 60 * 1000; + + // Initial run after startup + setTimeout(() => { + runAutoReminders(); + checkAndGenerateBills(); + }, 60000); + + setInterval(() => { + runAutoReminders(); + checkAndGenerateBills(); + }, interval); +}; + +module.exports = { startScheduler }; diff --git a/server/services/tenantService.js b/server/services/tenantService.js new file mode 100644 index 0000000..06d48e9 --- /dev/null +++ b/server/services/tenantService.js @@ -0,0 +1,34 @@ +const { runTenantMigrations } = require('../database'); +const { pool, quoteIdentifier } = require('../config/database'); + +const createTenantSchema = async (schemaName) => { + try { + await runTenantMigrations(schemaName); + } catch (error) { + await dropTenantSchema(schemaName); + throw error; + } +}; + +const dropTenantSchema = async (schemaName) => { + const client = await pool.connect(); + try { + await client.query(`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`); + } finally { + client.release(); + } +}; + +const schemaExists = async (schemaName) => { + const res = await pool.query( + 'SELECT 1 FROM information_schema.schemata WHERE schema_name = $1', + [schemaName] + ); + return Boolean(res.rows[0]); +}; + +module.exports = { + createTenantSchema, + dropTenantSchema, + schemaExists +}; diff --git a/server/tests/assets/README.md b/server/tests/assets/README.md new file mode 100644 index 0000000..10f0860 --- /dev/null +++ b/server/tests/assets/README.md @@ -0,0 +1,12 @@ +# Testing Image Uploads + +To test the `/repairs` or property image endpoints: + +1. In Postman, set the request type to `POST`. +2. Go to the `Body` tab. +3. Select `form-data`. +4. Add a key named `image`. +5. Change the key type from `Text` to `File`. +6. Select any small `.jpg` or `.png` from your computer. + +You can use any standard image for testing. The backend will store it in the `uploads/` directory and return the path. diff --git a/server/tests/e2e-endpoints.js b/server/tests/e2e-endpoints.js new file mode 100644 index 0000000..0a315bc --- /dev/null +++ b/server/tests/e2e-endpoints.js @@ -0,0 +1,818 @@ +#!/usr/bin/env node + +const crypto = require('crypto'); + +const BASE_URL = (process.env.BASE_URL || 'http://127.0.0.1:5000').replace(/\/$/, ''); +const API_URL = `${BASE_URL}/api`; +const RUN_PROVIDER_TESTS = /^true$/i.test(process.env.RUN_PROVIDER_TESTS || ''); +const ADMIN_EMAIL = process.env.SUPER_ADMIN_EMAIL || 'admin.e2e@example.com'; +const ADMIN_PASSWORD = process.env.SUPER_ADMIN_PASSWORD || 'Admin123!'; + +const runId = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; +const defaultPassword = 'Password123!'; +const staffPassword = 'StaffPass123!'; +const updatedStaffPassword = 'StaffPass456!'; + +const results = []; + +const endpointUrl = (path) => { + if (path === '/' || path === '/health') return `${BASE_URL}${path}`; + if (path.startsWith('/api/')) return `${BASE_URL}${path}`; + return `${API_URL}${path}`; +}; + +const preview = (value) => { + const text = typeof value === 'string' ? value : JSON.stringify(value); + return text && text.length > 500 ? `${text.slice(0, 500)}...` : text; +}; + +const parseResponse = async (response) => { + const text = await response.text(); + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json') && text) { + try { + return { data: JSON.parse(text), text }; + } catch { + return { data: null, text }; + } + } + return { data: text || null, text }; +}; + +const request = async (name, options) => { + const { + method = 'GET', + path, + token, + body, + formData, + headers = {}, + expected = 200, + assert, + skip = false, + note = '' + } = options; + const expectedStatuses = Array.isArray(expected) ? expected : [expected]; + + if (skip) { + results.push({ name, method, path, status: 'SKIP', expected: expectedStatuses.join(', '), note }); + console.log(`SKIP ${method} ${path} - ${name}${note ? ` (${note})` : ''}`); + return { skipped: true, ok: true, status: null, data: null }; + } + + const finalHeaders = { ...headers }; + if (token) finalHeaders.Authorization = `Bearer ${token}`; + + const init = { + method, + headers: finalHeaders, + redirect: 'manual' + }; + + if (body !== undefined) { + finalHeaders['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } else if (formData) { + init.body = formData; + } + + let status = 0; + let data = null; + let text = ''; + + try { + const response = await fetch(endpointUrl(path), init); + status = response.status; + ({ data, text } = await parseResponse(response)); + } catch (error) { + text = error.message; + } + + let ok = expectedStatuses.includes(status); + let assertionError = ''; + if (ok && assert) { + try { + const assertionResult = assert(data); + if (assertionResult === false) { + ok = false; + assertionError = 'response assertion returned false'; + } + } catch (error) { + ok = false; + assertionError = error.message; + } + } + + results.push({ + name, + method, + path, + status: ok ? 'PASS' : 'FAIL', + httpStatus: status, + expected: expectedStatuses.join(', '), + note + }); + + console.log(`${ok ? 'PASS' : 'FAIL'} ${method} ${path} - ${name} [${status || 'ERR'}]`); + if (!ok) { + console.log(` expected: ${expectedStatuses.join(', ')}`); + if (assertionError) console.log(` assertion: ${assertionError}`); + console.log(` response: ${preview(data || text)}`); + } + + return { ok, status, data, text }; +}; + +const requireValue = (value, label) => { + if (!value) { + throw new Error(`Missing required test state: ${label}`); + } + return value; +}; + +const asArray = (value) => Array.isArray(value) ? value : []; + +const findByEmail = (items, email) => asArray(items).find((item) => item.email === email); + +const makeFormData = (fields, fileField) => { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== null) form.append(key, String(value)); + } + if (fileField) { + form.append(fileField.name, fileField.blob, fileField.filename); + } + return form; +}; + +const main = async () => { + console.log(`Endpoint E2E base URL: ${BASE_URL}`); + console.log(`Run id: ${runId}`); + + await request('Root service banner', { path: '/', expected: 200 }); + await request('Health check with database readiness', { path: '/health', expected: 200 }); + await request('Public available properties before setup', { path: '/auth/properties/available', expected: 200 }); + + const adminLogin = await request('Super admin login', { + method: 'POST', + path: '/auth/login', + body: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + expected: 200 + }); + const adminToken = requireValue(adminLogin.data?.token, 'admin token'); + + const landlordEmail = `landlord.${runId}@example.com`; + const secondLandlordEmail = `landlord.second.${runId}@example.com`; + const landlordRegister = await request('Register landlord and create organization workspace', { + method: 'POST', + path: '/auth/register', + body: { + name: 'E2E Landlord', + email: landlordEmail, + password: defaultPassword, + role: 'landlord', + phoneNumber: '+254700000001' + }, + expected: 201 + }); + const organizationId = requireValue(landlordRegister.data?.organizationId, 'landlord organization id'); + + const secondLandlordRegister = await request('Register second landlord and separate organization workspace', { + method: 'POST', + path: '/auth/register', + body: { + name: 'E2E Second Landlord', + email: secondLandlordEmail, + password: defaultPassword, + role: 'landlord', + phoneNumber: '+254700000099' + }, + expected: 201 + }); + const secondOrganizationId = requireValue(secondLandlordRegister.data?.organizationId, 'second organization id'); + + const landlordLogin = await request('Landlord login', { + method: 'POST', + path: '/auth/login', + body: { email: landlordEmail, password: defaultPassword, organizationId }, + expected: 200 + }); + const landlordToken = requireValue(landlordLogin.data?.token, 'landlord token'); + const landlordUserId = requireValue(landlordLogin.data?.user?.id, 'landlord user id'); + + const secondLandlordLogin = await request('Second landlord login', { + method: 'POST', + path: '/auth/login', + body: { email: secondLandlordEmail, password: defaultPassword, organizationId: secondOrganizationId }, + expected: 200 + }); + const secondLandlordToken = requireValue(secondLandlordLogin.data?.token, 'second landlord token'); + + await request('First landlord cannot log into second organization context', { + method: 'POST', + path: '/auth/login', + body: { email: landlordEmail, password: defaultPassword, organizationId: secondOrganizationId }, + expected: 403 + }); + await request('Second landlord cannot log into first organization context', { + method: 'POST', + path: '/auth/login', + body: { email: secondLandlordEmail, password: defaultPassword, organizationId }, + expected: 403 + }); + + await request('Current landlord user', { path: '/auth/me', token: landlordToken, expected: 200 }); + await request('Initial organization staff list', { path: '/auth/staff', token: landlordToken, expected: 200 }); + + const staffEmail = `manager.${runId}@example.com`; + await request('Create organization staff', { + method: 'POST', + path: '/auth/staff', + token: landlordToken, + body: { + name: 'E2E Manager', + email: staffEmail, + password: staffPassword, + role: 'property_manager', + phoneNumber: '+254700000002' + }, + expected: 201 + }); + const staffLogin = await request('Staff login with temporary password', { + method: 'POST', + path: '/auth/login', + body: { email: staffEmail, password: staffPassword, organizationId }, + expected: 200 + }); + const staffToken = requireValue(staffLogin.data?.token, 'staff token'); + await request('Staff forced password change', { + method: 'POST', + path: '/auth/change-password', + token: staffToken, + body: { currentPassword: staffPassword, newPassword: updatedStaffPassword }, + expected: 200 + }); + + await request('Admin summary', { path: '/admin/summary', token: adminToken, expected: 200 }); + await request('Admin organizations list', { path: '/admin/organizations', token: adminToken, expected: 200 }); + await request('Admin audit logs', { path: '/admin/logs?limit=20', token: adminToken, expected: 200 }); + await request('Admin update organization billing', { + method: 'PUT', + path: `/admin/organizations/${organizationId}/billing`, + token: adminToken, + body: { pricePerUnit: 0, billingCycleMonths: 1, status: 'active' }, + expected: 200 + }); + + const propertyCreate = await request('Create property with units', { + method: 'POST', + path: '/properties', + token: landlordToken, + body: { + name: `E2E Residency ${runId}`, + address: 'Nairobi Test Avenue', + description: 'Created by endpoint E2E test', + type: 'apartment', + units: ['A1', 'A2', 'B1'] + }, + expected: 201 + }); + const propertyId = requireValue(propertyCreate.data?.id, 'property id'); + + const secondPropertyCreate = await request('Create property in second tenant workspace', { + method: 'POST', + path: '/properties', + token: secondLandlordToken, + body: { + name: `E2E Other Portfolio ${runId}`, + address: 'Mombasa Tenant Boundary Avenue', + units: ['Z1'] + }, + expected: 201 + }); + const secondPropertyId = requireValue(secondPropertyCreate.data?.id, 'second property id'); + + await request('Public available properties after setup', { path: '/auth/properties/available', expected: 200 }); + await request('List landlord properties excludes second tenant workspace', { + path: '/properties', + token: landlordToken, + expected: 200, + assert: (data) => { + const ids = asArray(data).map((property) => property.id); + if (!ids.includes(propertyId)) throw new Error('first organization property missing'); + if (ids.includes(secondPropertyId)) throw new Error('second organization property leaked into first organization'); + } + }); + await request('List second landlord properties excludes first tenant workspace', { + path: '/properties', + token: secondLandlordToken, + expected: 200, + assert: (data) => { + const ids = asArray(data).map((property) => property.id); + if (!ids.includes(secondPropertyId)) throw new Error('second organization property missing'); + if (ids.includes(propertyId)) throw new Error('first organization property leaked into second organization'); + } + }); + await request('Second landlord cannot read first organization property units', { + path: `/properties/${propertyId}/units`, + token: secondLandlordToken, + expected: 404 + }); + await request('Update property details', { + method: 'PUT', + path: `/properties/${propertyId}`, + token: landlordToken, + body: { + name: `E2E Residency Updated ${runId}`, + address: 'Nairobi Updated Avenue', + units: ['A1', 'A2', 'B1', 'C1'] + }, + expected: 200 + }); + await request('List units by property', { + path: `/properties/${propertyId}/units`, + token: landlordToken, + expected: 200 + }); + const createdUnit = await request('Create unit', { + method: 'POST', + path: '/units', + token: landlordToken, + body: { + propertyId, + unitNumber: 'D1', + rentAmount: 15000, + occupancyStatus: 'vacant', + meterReadings: { water: 10, electricity: 50 } + }, + expected: 201 + }); + const unitId = requireValue(createdUnit.data?.id, 'unit id'); + await request('Update unit', { + method: 'PUT', + path: `/units/${unitId}`, + token: landlordToken, + body: { + rentAmount: 15500, + occupancyStatus: 'reserved', + meterReadings: { water: 12, electricity: 55 }, + isActive: true + }, + expected: 200 + }); + + await request('Billing summary before chargeable subscription payment', { + path: '/billing/my-billing', + token: landlordToken, + expected: 200 + }); + await request('Billing subscription payment with zero balance', { + method: 'POST', + path: '/billing/pay-subscription', + token: landlordToken, + expected: 200 + }); + + const tenantEmail = `tenant.${runId}@example.com`; + const approveTenantEmail = `tenant.approve.${runId}@example.com`; + const rejectTenantEmail = `tenant.reject.${runId}@example.com`; + const inquiryEmail = `inquiry.${runId}@example.com`; + + await request('Register tenant application for direct assignment', { + method: 'POST', + path: '/auth/register', + body: { + name: 'E2E Tenant Direct', + email: tenantEmail, + password: defaultPassword, + role: 'tenant', + phoneNumber: '+254700000003', + interestedProperty: propertyId, + interestedUnit: 'A1', + organizationId + }, + expected: 201 + }); + await request('Register tenant application for approval flow', { + method: 'POST', + path: '/auth/register', + body: { + name: 'E2E Tenant Approval', + email: approveTenantEmail, + password: defaultPassword, + role: 'tenant', + phoneNumber: '+254700000004', + interestedProperty: propertyId, + interestedUnit: 'A2', + organizationId + }, + expected: 201 + }); + await request('Register tenant application for rejection flow', { + method: 'POST', + path: '/auth/register', + body: { + name: 'E2E Tenant Reject', + email: rejectTenantEmail, + password: defaultPassword, + role: 'tenant', + phoneNumber: '+254700000005', + interestedProperty: propertyId, + interestedUnit: 'B1', + organizationId + }, + expected: 201 + }); + await request('Submit guest inquiry', { + method: 'POST', + path: '/auth/inquiry', + body: { + name: 'E2E Inquiry', + email: inquiryEmail, + phoneNumber: '+254700000006', + interestedProperty: propertyId, + interestedUnit: 'C1', + organizationId + }, + expected: 201 + }); + await request('Tenant registration rejects property from another organization context', { + method: 'POST', + path: '/auth/register', + body: { + name: 'E2E Cross Tenant', + email: `tenant.cross.${runId}@example.com`, + password: defaultPassword, + role: 'tenant', + interestedProperty: propertyId, + interestedUnit: 'A1', + organizationId: secondOrganizationId + }, + expected: 404 + }); + + const adminPending = await request('Admin pending users', { + path: '/admin/users/pending', + token: adminToken, + expected: 200 + }); + const inquiryUser = requireValue(findByEmail(adminPending.data, inquiryEmail), 'inquiry pending user'); + await request('Admin approve pending user', { + method: 'POST', + path: `/admin/users/${inquiryUser.id}/approve`, + token: adminToken, + expected: 200 + }); + + const pendingRegistrations = await request('Landlord pending registrations', { + path: '/pending-registrations', + token: landlordToken, + expected: 200 + }); + const directTenantUser = requireValue(findByEmail(pendingRegistrations.data, tenantEmail), 'direct tenant pending user'); + const approveTenantUser = requireValue(findByEmail(pendingRegistrations.data, approveTenantEmail), 'approval tenant pending user'); + const rejectTenantUser = requireValue(findByEmail(pendingRegistrations.data, rejectTenantEmail), 'reject tenant pending user'); + + await request('Reject tenant registration', { + method: 'POST', + path: `/reject-registration/${rejectTenantUser.id}`, + token: landlordToken, + expected: 200 + }); + + const directTenant = await request('Create tenant profile directly', { + method: 'POST', + path: '/tenants', + token: landlordToken, + body: { + user: directTenantUser.id, + property: propertyId, + unit: 'A1', + rentAmount: 12000, + dueDate: 1, + status: 'active', + leaseStart: '2026-01-01', + leaseEnd: '2026-12-31' + }, + expected: 201 + }); + const tenantProfileId = requireValue(directTenant.data?.id, 'tenant profile id'); + + await request('Approve tenant registration and generate lease entry', { + method: 'POST', + path: `/approve-registration/${approveTenantUser.id}`, + token: landlordToken, + body: { + rentAmount: 13000, + depositAmount: 13000, + dueDate: 1, + leaseStart: '2026-01-01', + leaseEnd: '2026-12-31' + }, + expected: 200 + }); + await request('List property tenants', { + path: `/properties/${propertyId}/tenants`, + token: landlordToken, + expected: 200 + }); + + const tenantLogin = await request('Tenant login', { + method: 'POST', + path: '/auth/login', + body: { email: tenantEmail, password: defaultPassword, organizationId }, + expected: 200 + }); + const tenantToken = requireValue(tenantLogin.data?.token, 'tenant token'); + await request('Current tenant user', { path: '/auth/me', token: tenantToken, expected: 200 }); + + await request('Toggle global chat on', { + method: 'POST', + path: '/messages/toggle-global', + token: landlordToken, + body: { enabled: true }, + expected: 200 + }); + await request('Post global message', { + method: 'POST', + path: '/messages', + token: landlordToken, + body: { content: `Global E2E message ${runId}` }, + expected: 201 + }); + await request('Get global messages', { path: '/messages', token: landlordToken, expected: 200 }); + await request('Post property message as landlord', { + method: 'POST', + path: '/messages', + token: landlordToken, + body: { content: `Property E2E message ${runId}`, propertyId }, + expected: 201 + }); + await request('Get property messages as tenant default chat', { + path: '/messages', + token: tenantToken, + expected: 200 + }); + await request('Post property message as tenant default chat', { + method: 'POST', + path: '/messages', + token: tenantToken, + body: { content: `Tenant E2E reply ${runId}` }, + expected: 201 + }); + + const repairCreate = await request('Create repair request as tenant', { + method: 'POST', + path: '/repairs', + token: tenantToken, + body: { + propertyId, + unit: 'A1', + category: 'plumbing', + description: 'Kitchen sink leak from endpoint E2E test' + }, + expected: 201 + }); + const repairId = requireValue(repairCreate.data?.id, 'repair request id'); + await request('List repair requests as landlord', { path: '/repairs', token: landlordToken, expected: 200 }); + await request('Update repair request', { + method: 'PUT', + path: `/repairs/${repairId}`, + token: landlordToken, + body: { + status: 'in-progress', + landlordResponse: 'Technician assigned', + technicianDetails: 'E2E plumber', + assignedTo: landlordUserId, + cost: 500 + }, + expected: 200 + }); + + const tenantNotifications = await request('List tenant notifications', { + path: '/notifications', + token: tenantToken, + expected: 200 + }); + const notificationId = asArray(tenantNotifications.data)[0]?.id; + await request('Mark tenant notification read', { + method: 'PATCH', + path: `/notifications/${notificationId || crypto.randomUUID()}/read`, + token: tenantToken, + expected: notificationId ? 200 : 404 + }); + + await request('Create anonymous suggestion as tenant', { + method: 'POST', + path: '/suggestions', + token: tenantToken, + body: { content: `Please add hallway lighting ${runId}` }, + expected: 201 + }); + await request('List suggestions as landlord', { path: '/suggestions', token: landlordToken, expected: 200 }); + + await request('Get landlord payment settings', { + path: '/payments/settings', + token: landlordToken, + expected: 200 + }); + await request('Update landlord payment settings', { + method: 'PUT', + path: '/payments/settings', + token: landlordToken, + body: { + mpesaShortcode: '174379', + mpesaConsumerKey: 'test-consumer-key', + mpesaConsumerSecret: 'test-consumer-secret', + mpesaPasskey: 'test-passkey', + bankDetails: { + bankName: 'E2E Bank', + accountNumber: '1234567890', + accountName: 'Apex E2E' + }, + paymentMethods: ['mpesa', 'bank_transfer'] + }, + expected: 200 + }); + await request('Get landlord payment methods', { + path: '/payments/methods', + token: tenantToken, + expected: 200 + }); + await request('List payments as landlord', { path: '/payments', token: landlordToken, expected: 200 }); + await request('STK push rejects unknown tenant before provider call', { + method: 'POST', + path: '/payments/stkpush', + token: landlordToken, + body: { + amount: 1, + phoneNumber: '254712345678', + tenantId: crypto.randomUUID() + }, + expected: 404 + }); + await request('Live STK push through Safaricom sandbox', { + method: 'POST', + path: '/payments/stkpush', + token: landlordToken, + body: { + amount: 1, + phoneNumber: process.env.E2E_MPESA_PHONE || '254712345678', + tenantId: tenantProfileId + }, + expected: 200, + skip: !RUN_PROVIDER_TESTS, + note: 'set RUN_PROVIDER_TESTS=true with valid M-Pesa credentials' + }); + await request('M-Pesa callback rejects invalid payload', { + method: 'POST', + path: '/payments/callback', + body: { invalid: true }, + expected: 400 + }); + + await request('Toggle auto reminders', { + method: 'POST', + path: '/reminders/toggle', + token: landlordToken, + body: { enabled: true }, + expected: 200 + }); + await request('Update reminder settings', { + method: 'POST', + path: '/reminders/settings', + token: landlordToken, + body: { beforeDays: 3, afterDays: 3 }, + expected: 200 + }); + await request('Trigger manual reminder', { + method: 'POST', + path: '/reminders/manual', + token: landlordToken, + body: { tenantId: tenantProfileId }, + expected: 200 + }); + await request('Trigger all reminders', { + method: 'POST', + path: '/reminders/trigger-all', + token: landlordToken, + expected: 200 + }); + + await request('Tenant lease lookup', { path: '/leases/tenant', token: tenantToken, expected: 200 }); + const landlordLeases = await request('Landlord leases list', { + path: '/leases/landlord', + token: landlordToken, + expected: 200 + }); + const leaseId = asArray(landlordLeases.data)[0]?.id; + await request('View generated non-Cloudinary lease entry', { + path: `/leases/view/${leaseId || crypto.randomUUID()}`, + token: landlordToken, + expected: leaseId ? 400 : 404 + }); + await request('Lease upload rejects missing multipart file', { + method: 'POST', + path: '/leases/upload', + token: landlordToken, + body: { + tenantId: directTenantUser.id, + propertyId, + unit: 'A1', + startDate: '2026-01-01', + endDate: '2026-12-31', + depositAmount: 12000, + penaltyTerms: 'Standard terms' + }, + expected: 400 + }); + + const leaseForm = makeFormData({ + tenantId: directTenantUser.id, + propertyId, + unit: 'A1', + startDate: '2026-01-01', + endDate: '2026-12-31', + depositAmount: 12000, + penaltyTerms: 'Standard terms' + }, { + name: 'lease', + blob: new Blob(['E2E lease document'], { type: 'application/pdf' }), + filename: 'e2e-lease.pdf' + }); + await request('Live Cloudinary lease upload', { + method: 'POST', + path: '/leases/upload', + token: landlordToken, + formData: leaseForm, + expected: 201, + skip: !RUN_PROVIDER_TESTS, + note: 'set RUN_PROVIDER_TESTS=true with valid Cloudinary credentials' + }); + + if (leaseId) { + await request('Delete generated lease entry', { + method: 'DELETE', + path: `/leases/${leaseId}`, + token: landlordToken, + expected: 200 + }); + } + await request('Delete repair request', { + method: 'DELETE', + path: `/repairs/${repairId}`, + token: landlordToken, + expected: 200 + }); + + await request('Paystack webhook rejects bad signature', { + method: 'POST', + path: '/webhooks/paystack', + headers: { 'x-paystack-signature': 'invalid-signature' }, + body: { + event: 'charge.success', + data: { + amount: 100, + reference: `bad-${runId}`, + metadata: { organizationId, type: 'subscription_payment' } + } + }, + expected: 401 + }); + await request('Live Paystack checkout with billable balance', { + method: 'POST', + path: '/billing/pay-subscription', + token: landlordToken, + expected: 200, + skip: !RUN_PROVIDER_TESTS, + note: 'set RUN_PROVIDER_TESTS=true with valid Paystack credentials and nonzero billing' + }); + + await request('Delete property and tenant-scoped records', { + method: 'DELETE', + path: `/properties/${propertyId}`, + token: landlordToken, + expected: 200 + }); + await request('Delete second tenant workspace property', { + method: 'DELETE', + path: `/properties/${secondPropertyId}`, + token: secondLandlordToken, + expected: 200 + }); + + const passCount = results.filter((result) => result.status === 'PASS').length; + const failCount = results.filter((result) => result.status === 'FAIL').length; + const skipCount = results.filter((result) => result.status === 'SKIP').length; + + console.log(''); + console.log(`Endpoint E2E summary: ${passCount} passed, ${failCount} failed, ${skipCount} skipped`); + if (failCount > 0) { + process.exitCode = 1; + } +}; + +main().catch((error) => { + console.error(`FATAL ${error.message}`); + process.exitCode = 1; +}); diff --git a/server/tests/test_endpoints.sh b/server/tests/test_endpoints.sh new file mode 100644 index 0000000..0a714d5 --- /dev/null +++ b/server/tests/test_endpoints.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +# Compatibility wrapper. The maintained multi-tenant endpoint suite is +# tests/e2e-endpoints.js and is also available through npm. +npm run test:endpoints