Add JWT auth with protected routes and per-user data isolation
Frontend: - Login and Register pages wired up to API - PrivateRoute redirects unauthenticated users to /login - Token persisted in localStorage, restored on page load - Axios instance automatically attaches Bearer token, redirects on 401 Backend: - userId field added to all models (Product, Order, Customer, Expense) - All queries scoped to authenticated user's userId - Register/login return JWT token Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
42f0587cf6
commit
0d42d97d70
15 changed files with 304 additions and 94 deletions
|
|
@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { store } from './store';
|
import { store } from './store';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
import Products from './pages/Products';
|
import Products from './pages/Products';
|
||||||
import Orders from './pages/Orders';
|
import Orders from './pages/Orders';
|
||||||
import Analytics from './pages/Analytics';
|
import Analytics from './pages/Analytics';
|
||||||
|
|
@ -11,6 +12,7 @@ import Expenses from './pages/Expenses';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import DataImport from './pages/DataImport';
|
import DataImport from './pages/DataImport';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
|
import Register from './pages/Register';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -21,15 +23,18 @@ function App() {
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/register" element={<Register />} />
|
||||||
<Route index element={<Navigate to="/analytics" replace />} />
|
<Route element={<PrivateRoute />}>
|
||||||
<Route path="analytics" element={<Analytics />} />
|
<Route path="/" element={<Layout />}>
|
||||||
<Route path="profit-analysis" element={<ProfitAnalysis />} />
|
<Route index element={<Navigate to="/analytics" replace />} />
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="analytics" element={<Analytics />} />
|
||||||
<Route path="orders" element={<Orders />} />
|
<Route path="profit-analysis" element={<ProfitAnalysis />} />
|
||||||
<Route path="data-import" element={<DataImport />} />
|
<Route path="products" element={<Products />} />
|
||||||
<Route path="expenses" element={<Expenses />} />
|
<Route path="orders" element={<Orders />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="data-import" element={<DataImport />} />
|
||||||
|
<Route path="expenses" element={<Expenses />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,4 +43,4 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
10
client/src/components/PrivateRoute.tsx
Normal file
10
client/src/components/PrivateRoute.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { RootState } from '../store';
|
||||||
|
|
||||||
|
const PrivateRoute = () => {
|
||||||
|
const isAuthenticated = useSelector((state: RootState) => state.auth.isAuthenticated);
|
||||||
|
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivateRoute;
|
||||||
|
|
@ -1,4 +1,29 @@
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { loginStart, loginSuccess, loginFailure } from '../store/slices/authSlice';
|
||||||
|
import { RootState } from '../store';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { loading, error } = useSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dispatch(loginStart());
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/login', { email, password });
|
||||||
|
dispatch(loginSuccess(data));
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
dispatch(loginFailure(err.response?.data?.message || 'Login failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-md w-full space-y-8">
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
|
@ -6,34 +31,46 @@ const Login = () => {
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
Sign in to your account
|
Sign in to your account
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Or{' '}
|
||||||
|
<Link to="/register" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||||
|
create a new account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-8 space-y-6">
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
<div>
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Email address"
|
placeholder="Email address"
|
||||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<button
|
||||||
<button
|
type="submit"
|
||||||
type="submit"
|
disabled={loading}
|
||||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Sign in
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Login;
|
export default Login;
|
||||||
|
|
|
||||||
86
client/src/pages/Register.tsx
Normal file
86
client/src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { loginStart, loginSuccess, loginFailure } from '../store/slices/authSlice';
|
||||||
|
import { RootState } from '../store';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
const Register = () => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { loading, error } = useSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dispatch(loginStart());
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/auth/register', { name, email, password });
|
||||||
|
dispatch(loginSuccess(data));
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
dispatch(loginFailure(err.response?.data?.message || 'Registration failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Create your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Full name"
|
||||||
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Email address"
|
||||||
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Password (min 8 characters)"
|
||||||
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
user: any | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storedToken = localStorage.getItem('token');
|
||||||
|
const storedUser = localStorage.getItem('user');
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
isAuthenticated: false,
|
isAuthenticated: !!storedToken,
|
||||||
user: null,
|
user: storedUser ? JSON.parse(storedUser) : null,
|
||||||
token: null,
|
token: storedToken,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -24,12 +33,14 @@ const authSlice = createSlice({
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
},
|
},
|
||||||
loginSuccess: (state, action: PayloadAction<{ user: any; token: string }>) => {
|
loginSuccess: (state, action: PayloadAction<{ user: User; token: string }>) => {
|
||||||
state.isAuthenticated = true;
|
state.isAuthenticated = true;
|
||||||
state.user = action.payload.user;
|
state.user = action.payload.user;
|
||||||
state.token = action.payload.token;
|
state.token = action.payload.token;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
|
localStorage.setItem('token', action.payload.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(action.payload.user));
|
||||||
},
|
},
|
||||||
loginFailure: (state, action: PayloadAction<string>) => {
|
loginFailure: (state, action: PayloadAction<string>) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
|
|
@ -40,9 +51,11 @@ const authSlice = createSlice({
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.token = null;
|
state.token = null;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { loginStart, loginSuccess, loginFailure, logout } = authSlice.actions;
|
export const { loginStart, loginSuccess, loginFailure, logout } = authSlice.actions;
|
||||||
export default authSlice.reducer;
|
export default authSlice.reducer;
|
||||||
|
|
|
||||||
27
client/src/utils/api.ts
Normal file
27
client/src/utils/api.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
@ -14,6 +14,7 @@ export interface ICustomer extends Document {
|
||||||
};
|
};
|
||||||
totalOrders: number;
|
totalOrders: number;
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
|
userId: mongoose.Types.ObjectId;
|
||||||
dateCreated: Date;
|
dateCreated: Date;
|
||||||
dateUpdated: Date;
|
dateUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +33,7 @@ const CustomerSchema: Schema = new Schema({
|
||||||
},
|
},
|
||||||
totalOrders: { type: Number, default: 0 },
|
totalOrders: { type: Number, default: 0 },
|
||||||
totalSpent: { type: Number, default: 0 },
|
totalSpent: { type: Number, default: 0 },
|
||||||
|
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
dateCreated: { type: Date, default: Date.now },
|
dateCreated: { type: Date, default: Date.now },
|
||||||
dateUpdated: { type: Date, default: Date.now },
|
dateUpdated: { type: Date, default: Date.now },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface IExpense extends Document {
|
||||||
date: Date;
|
date: Date;
|
||||||
receiptUrl?: string;
|
receiptUrl?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
userId: mongoose.Types.ObjectId;
|
||||||
dateCreated: Date;
|
dateCreated: Date;
|
||||||
dateUpdated: Date;
|
dateUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +23,7 @@ const ExpenseSchema: Schema = new Schema({
|
||||||
date: { type: Date, required: true },
|
date: { type: Date, required: true },
|
||||||
receiptUrl: { type: String },
|
receiptUrl: { type: String },
|
||||||
notes: { type: String },
|
notes: { type: String },
|
||||||
|
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
dateCreated: { type: Date, default: Date.now },
|
dateCreated: { type: Date, default: Date.now },
|
||||||
dateUpdated: { type: Date, default: Date.now },
|
dateUpdated: { type: Date, default: Date.now },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export interface IOrder extends Document {
|
||||||
dateOrdered: Date;
|
dateOrdered: Date;
|
||||||
dateShipped?: Date;
|
dateShipped?: Date;
|
||||||
dateDelivered?: Date;
|
dateDelivered?: Date;
|
||||||
|
userId: mongoose.Types.ObjectId;
|
||||||
dateCreated: Date;
|
dateCreated: Date;
|
||||||
dateUpdated: Date;
|
dateUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +79,7 @@ const OrderSchema: Schema = new Schema({
|
||||||
dateOrdered: { type: Date, required: true },
|
dateOrdered: { type: Date, required: true },
|
||||||
dateShipped: { type: Date },
|
dateShipped: { type: Date },
|
||||||
dateDelivered: { type: Date },
|
dateDelivered: { type: Date },
|
||||||
|
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
dateCreated: { type: Date, default: Date.now },
|
dateCreated: { type: Date, default: Date.now },
|
||||||
dateUpdated: { type: Date, default: Date.now }
|
dateUpdated: { type: Date, default: Date.now }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export interface IProduct extends Document {
|
||||||
materials: string[];
|
materials: string[];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
etsyListingId?: string;
|
etsyListingId?: string;
|
||||||
|
userId: mongoose.Types.ObjectId;
|
||||||
dateCreated: Date;
|
dateCreated: Date;
|
||||||
dateUpdated: Date;
|
dateUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +63,7 @@ const ProductSchema: Schema = new Schema({
|
||||||
materials: [{ type: String }],
|
materials: [{ type: String }],
|
||||||
isActive: { type: Boolean, default: true },
|
isActive: { type: Boolean, default: true },
|
||||||
etsyListingId: { type: String },
|
etsyListingId: { type: String },
|
||||||
|
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
dateCreated: { type: Date, default: Date.now },
|
dateCreated: { type: Date, default: Date.now },
|
||||||
dateUpdated: { type: Date, default: Date.now }
|
dateUpdated: { type: Date, default: Date.now }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,42 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
import Order from '../models/Order';
|
import Order from '../models/Order';
|
||||||
import Product from '../models/Product';
|
import Product from '../models/Product';
|
||||||
import Customer from '../models/Customer';
|
import Customer from '../models/Customer';
|
||||||
import Expense from '../models/Expense';
|
import Expense from '../models/Expense';
|
||||||
|
import { AuthRequest } from '../middleware/authenticate';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/dashboard', async (req: Request, res: Response) => {
|
router.get('/dashboard', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
const userId = new mongoose.Types.ObjectId(req.userId);
|
||||||
|
|
||||||
const [totalOrders, totalProducts, totalCustomers, revenueResult, expenseResult, recentOrders] = await Promise.all([
|
const [totalOrders, totalProducts, totalCustomers, revenueResult, expenseResult, recentOrders] = await Promise.all([
|
||||||
Order.countDocuments({ paymentStatus: 'paid' }),
|
Order.countDocuments({ userId, paymentStatus: 'paid' }),
|
||||||
Product.countDocuments({ isActive: true }),
|
Product.countDocuments({ userId, isActive: true }),
|
||||||
Customer.countDocuments(),
|
Customer.countDocuments({ userId }),
|
||||||
Order.aggregate([
|
Order.aggregate([
|
||||||
{ $match: { paymentStatus: 'paid' } },
|
{ $match: { userId, paymentStatus: 'paid' } },
|
||||||
{ $group: { _id: null, total: { $sum: '$total' } } },
|
{ $group: { _id: null, total: { $sum: '$total' } } },
|
||||||
]),
|
]),
|
||||||
Expense.aggregate([
|
Expense.aggregate([
|
||||||
|
{ $match: { userId } },
|
||||||
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
||||||
]),
|
]),
|
||||||
Order.find({ paymentStatus: 'paid' })
|
Order.find({ userId, paymentStatus: 'paid' })
|
||||||
.populate('customerId', 'name email')
|
.populate('customerId', 'name email')
|
||||||
.sort({ dateOrdered: -1 })
|
.sort({ dateOrdered: -1 })
|
||||||
.limit(5),
|
.limit(5),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalRevenue = revenueResult[0]?.total ?? 0;
|
|
||||||
const totalExpenses = expenseResult[0]?.total ?? 0;
|
|
||||||
|
|
||||||
const twelveMonthsAgo = new Date();
|
const twelveMonthsAgo = new Date();
|
||||||
twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 11);
|
twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 11);
|
||||||
twelveMonthsAgo.setDate(1);
|
twelveMonthsAgo.setDate(1);
|
||||||
twelveMonthsAgo.setHours(0, 0, 0, 0);
|
twelveMonthsAgo.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const salesChart = await Order.aggregate([
|
const salesChart = await Order.aggregate([
|
||||||
{ $match: { paymentStatus: 'paid', dateOrdered: { $gte: twelveMonthsAgo } } },
|
{ $match: { userId, paymentStatus: 'paid', dateOrdered: { $gte: twelveMonthsAgo } } },
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: { year: { $year: '$dateOrdered' }, month: { $month: '$dateOrdered' } },
|
_id: { year: { $year: '$dateOrdered' }, month: { $month: '$dateOrdered' } },
|
||||||
|
|
@ -46,8 +48,8 @@ router.get('/dashboard', async (req: Request, res: Response) => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalRevenue,
|
totalRevenue: revenueResult[0]?.total ?? 0,
|
||||||
totalExpenses,
|
totalExpenses: expenseResult[0]?.total ?? 0,
|
||||||
totalOrders,
|
totalOrders,
|
||||||
totalProducts,
|
totalProducts,
|
||||||
totalCustomers,
|
totalCustomers,
|
||||||
|
|
@ -59,10 +61,11 @@ router.get('/dashboard', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/sales', async (req: Request, res: Response) => {
|
router.get('/sales', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
const userId = new mongoose.Types.ObjectId(req.userId);
|
||||||
const { from, to } = req.query;
|
const { from, to } = req.query;
|
||||||
const match: any = { paymentStatus: 'paid' };
|
const match: any = { userId, paymentStatus: 'paid' };
|
||||||
if (from || to) {
|
if (from || to) {
|
||||||
match.dateOrdered = {};
|
match.dateOrdered = {};
|
||||||
if (from) match.dateOrdered.$gte = new Date(from as string);
|
if (from) match.dateOrdered.$gte = new Date(from as string);
|
||||||
|
|
@ -87,10 +90,11 @@ router.get('/sales', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/products', async (req: Request, res: Response) => {
|
router.get('/products', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
const userId = new mongoose.Types.ObjectId(req.userId);
|
||||||
const topProducts = await Order.aggregate([
|
const topProducts = await Order.aggregate([
|
||||||
{ $match: { paymentStatus: 'paid' } },
|
{ $match: { userId, paymentStatus: 'paid' } },
|
||||||
{ $unwind: '$items' },
|
{ $unwind: '$items' },
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
|
|
@ -103,20 +107,18 @@ router.get('/products', async (req: Request, res: Response) => {
|
||||||
{ $sort: { totalRevenue: -1 } },
|
{ $sort: { totalRevenue: -1 } },
|
||||||
{ $limit: 10 },
|
{ $limit: 10 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json(topProducts);
|
res.json(topProducts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Failed to fetch product analytics', error: err });
|
res.status(500).json({ message: 'Failed to fetch product analytics', error: err });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/customers', async (req: Request, res: Response) => {
|
router.get('/customers', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const topCustomers = await Customer.find()
|
const topCustomers = await Customer.find({ userId: req.userId })
|
||||||
.sort({ totalSpent: -1 })
|
.sort({ totalSpent: -1 })
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.select('name email totalOrders totalSpent');
|
.select('name email totalOrders totalSpent');
|
||||||
|
|
||||||
res.json(topCustomers);
|
res.json(topCustomers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Failed to fetch customer analytics', error: err });
|
res.status(500).json({ message: 'Failed to fetch customer analytics', error: err });
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,26 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import Customer from '../models/Customer';
|
import Customer from '../models/Customer';
|
||||||
|
import { AuthRequest } from '../middleware/authenticate';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, limit = 20 } = req.query;
|
const { page = 1, limit = 20 } = req.query;
|
||||||
const customers = await Customer.find()
|
const customers = await Customer.find({ userId: req.userId })
|
||||||
.sort({ dateCreated: -1 })
|
.sort({ dateCreated: -1 })
|
||||||
.limit(Number(limit))
|
.limit(Number(limit))
|
||||||
.skip((Number(page) - 1) * Number(limit));
|
.skip((Number(page) - 1) * Number(limit));
|
||||||
const total = await Customer.countDocuments();
|
const total = await Customer.countDocuments({ userId: req.userId });
|
||||||
res.json({ customers, total, page: Number(page), limit: Number(limit) });
|
res.json({ customers, total, page: Number(page), limit: Number(limit) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Failed to fetch customers', error: err });
|
res.status(500).json({ message: 'Failed to fetch customers', error: err });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customer = new Customer(req.body);
|
const customer = new Customer({ ...req.body, userId: req.userId });
|
||||||
await customer.save();
|
await customer.save();
|
||||||
res.status(201).json(customer);
|
res.status(201).json(customer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -27,9 +28,9 @@ router.post('/', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customer = await Customer.findById(req.params.id);
|
const customer = await Customer.findOne({ _id: req.params.id, userId: req.userId });
|
||||||
if (!customer) return res.status(404).json({ message: 'Customer not found' });
|
if (!customer) return res.status(404).json({ message: 'Customer not found' });
|
||||||
res.json(customer);
|
res.json(customer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -37,9 +38,13 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const customer = await Customer.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
|
const customer = await Customer.findOneAndUpdate(
|
||||||
|
{ _id: req.params.id, userId: req.userId },
|
||||||
|
req.body,
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
if (!customer) return res.status(404).json({ message: 'Customer not found' });
|
if (!customer) return res.status(404).json({ message: 'Customer not found' });
|
||||||
res.json(customer);
|
res.json(customer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import Expense from '../models/Expense';
|
import Expense from '../models/Expense';
|
||||||
|
import { AuthRequest } from '../middleware/authenticate';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, limit = 20, category } = req.query;
|
const { page = 1, limit = 20, category } = req.query;
|
||||||
const filter: any = {};
|
const filter: any = { userId: req.userId };
|
||||||
if (category) filter.category = category;
|
if (category) filter.category = category;
|
||||||
|
|
||||||
const expenses = await Expense.find(filter)
|
const expenses = await Expense.find(filter)
|
||||||
|
|
@ -21,9 +22,9 @@ router.get('/', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const expense = new Expense(req.body);
|
const expense = new Expense({ ...req.body, userId: req.userId });
|
||||||
await expense.save();
|
await expense.save();
|
||||||
res.status(201).json(expense);
|
res.status(201).json(expense);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -31,9 +32,9 @@ router.post('/', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const expense = await Expense.findById(req.params.id);
|
const expense = await Expense.findOne({ _id: req.params.id, userId: req.userId });
|
||||||
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
||||||
res.json(expense);
|
res.json(expense);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -41,9 +42,13 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const expense = await Expense.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
|
const expense = await Expense.findOneAndUpdate(
|
||||||
|
{ _id: req.params.id, userId: req.userId },
|
||||||
|
req.body,
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
||||||
res.json(expense);
|
res.json(expense);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -51,9 +56,9 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', async (req: Request, res: Response) => {
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const expense = await Expense.findByIdAndDelete(req.params.id);
|
const expense = await Expense.findOneAndDelete({ _id: req.params.id, userId: req.userId });
|
||||||
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
if (!expense) return res.status(404).json({ message: 'Expense not found' });
|
||||||
res.json({ message: 'Expense deleted' });
|
res.json({ message: 'Expense deleted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import Order from '../models/Order';
|
import Order from '../models/Order';
|
||||||
import Customer from '../models/Customer';
|
import Customer from '../models/Customer';
|
||||||
|
import { AuthRequest } from '../middleware/authenticate';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, limit = 20, status, paymentStatus } = req.query;
|
const { page = 1, limit = 20, status, paymentStatus } = req.query;
|
||||||
const filter: any = {};
|
const filter: any = { userId: req.userId };
|
||||||
if (status) filter.status = status;
|
if (status) filter.status = status;
|
||||||
if (paymentStatus) filter.paymentStatus = paymentStatus;
|
if (paymentStatus) filter.paymentStatus = paymentStatus;
|
||||||
|
|
||||||
|
|
@ -24,14 +25,15 @@ router.get('/', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const order = new Order(req.body);
|
const order = new Order({ ...req.body, userId: req.userId });
|
||||||
await order.save();
|
await order.save();
|
||||||
|
|
||||||
await Customer.findByIdAndUpdate(order.customerId, {
|
await Customer.findOneAndUpdate(
|
||||||
$inc: { totalOrders: 1, totalSpent: order.total },
|
{ _id: order.customerId, userId: req.userId },
|
||||||
});
|
{ $inc: { totalOrders: 1, totalSpent: order.total } }
|
||||||
|
);
|
||||||
|
|
||||||
res.status(201).json(order);
|
res.status(201).json(order);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -39,9 +41,10 @@ router.post('/', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const order = await Order.findById(req.params.id).populate('customerId', 'name email');
|
const order = await Order.findOne({ _id: req.params.id, userId: req.userId })
|
||||||
|
.populate('customerId', 'name email');
|
||||||
if (!order) return res.status(404).json({ message: 'Order not found' });
|
if (!order) return res.status(404).json({ message: 'Order not found' });
|
||||||
res.json(order);
|
res.json(order);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -49,9 +52,13 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const order = await Order.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
|
const order = await Order.findOneAndUpdate(
|
||||||
|
{ _id: req.params.id, userId: req.userId },
|
||||||
|
req.body,
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
if (!order) return res.status(404).json({ message: 'Order not found' });
|
if (!order) return res.status(404).json({ message: 'Order not found' });
|
||||||
res.json(order);
|
res.json(order);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Response } from 'express';
|
||||||
import Product from '../models/Product';
|
import Product from '../models/Product';
|
||||||
|
import { AuthRequest } from '../middleware/authenticate';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, limit = 20, category, active } = req.query;
|
const { page = 1, limit = 20, category, active } = req.query;
|
||||||
const filter: any = {};
|
const filter: any = { userId: req.userId };
|
||||||
if (category) filter.category = category;
|
if (category) filter.category = category;
|
||||||
if (active !== undefined) filter.isActive = active === 'true';
|
if (active !== undefined) filter.isActive = active === 'true';
|
||||||
|
|
||||||
|
|
@ -22,9 +23,9 @@ router.get('/', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req: Request, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const product = new Product(req.body);
|
const product = new Product({ ...req.body, userId: req.userId });
|
||||||
await product.save();
|
await product.save();
|
||||||
res.status(201).json(product);
|
res.status(201).json(product);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -32,9 +33,9 @@ router.post('/', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const product = await Product.findById(req.params.id);
|
const product = await Product.findOne({ _id: req.params.id, userId: req.userId });
|
||||||
if (!product) return res.status(404).json({ message: 'Product not found' });
|
if (!product) return res.status(404).json({ message: 'Product not found' });
|
||||||
res.json(product);
|
res.json(product);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -42,9 +43,13 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req: Request, res: Response) => {
|
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const product = await Product.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
|
const product = await Product.findOneAndUpdate(
|
||||||
|
{ _id: req.params.id, userId: req.userId },
|
||||||
|
req.body,
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
);
|
||||||
if (!product) return res.status(404).json({ message: 'Product not found' });
|
if (!product) return res.status(404).json({ message: 'Product not found' });
|
||||||
res.json(product);
|
res.json(product);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -52,9 +57,9 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', async (req: Request, res: Response) => {
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const product = await Product.findByIdAndDelete(req.params.id);
|
const product = await Product.findOneAndDelete({ _id: req.params.id, userId: req.userId });
|
||||||
if (!product) return res.status(404).json({ message: 'Product not found' });
|
if (!product) return res.status(404).json({ message: 'Product not found' });
|
||||||
res.json({ message: 'Product deleted' });
|
res.json({ message: 'Product deleted' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue