Add ui for csv update and import (broken) + fix build issues

This commit is contained in:
2025-01-10 00:56:14 -05:00
parent 8bdd188dfe
commit dbdf77331c
17 changed files with 406 additions and 67 deletions

View File

@@ -6,8 +6,8 @@ const dotenv = require('dotenv');
// For testing purposes, limit the number of rows to import (0 = no limit) // For testing purposes, limit the number of rows to import (0 = no limit)
const PRODUCTS_TEST_LIMIT = 0; const PRODUCTS_TEST_LIMIT = 0;
const ORDERS_TEST_LIMIT = 5000; const ORDERS_TEST_LIMIT = 10000;
const PURCHASE_ORDERS_TEST_LIMIT = 0; const PURCHASE_ORDERS_TEST_LIMIT = 10000;
dotenv.config({ path: path.join(__dirname, '../.env') }); dotenv.config({ path: path.join(__dirname, '../.env') });

View File

@@ -4,18 +4,37 @@ const mysql = require('mysql2/promise');
const productsRouter = require('./routes/products'); const productsRouter = require('./routes/products');
const dashboardRouter = require('./routes/dashboard'); const dashboardRouter = require('./routes/dashboard');
const ordersRouter = require('./routes/orders'); const ordersRouter = require('./routes/orders');
const csvRoutes = require('./routes/csv');
const app = express(); const app = express();
app.use(cors()); // Debug middleware to log all requests
app.use((req, res, next) => {
console.log(`[App Debug] ${new Date().toISOString()} - ${req.method} ${req.path}`);
next();
});
// Configure CORS with specific options
app.use(cors({
origin: [
'http://localhost:5173', // Local development
'https://inventory.kent.pw', // Production frontend
/\.kent\.pw$/ // Any subdomain of kent.pw
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
optionsSuccessStatus: 200
}));
app.use(express.json()); app.use(express.json());
// Database connection // Database connection
const pool = mysql.createPool({ const pool = mysql.createPool({
host: 'localhost', host: process.env.DB_HOST || 'localhost',
user: 'root', user: process.env.DB_USER || 'root',
password: '', password: process.env.DB_PASSWORD || '',
database: 'inventory', database: process.env.DB_NAME || 'inventory',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0 queueLimit: 0
@@ -24,12 +43,73 @@ const pool = mysql.createPool({
// Make db pool available in routes // Make db pool available in routes
app.locals.pool = pool; app.locals.pool = pool;
// Routes // Debug endpoint to list all registered routes
app.get('/api/debug/routes', (req, res) => {
console.log('Debug routes endpoint hit');
const routes = [];
app._router.stack.forEach(middleware => {
if (middleware.route) {
routes.push({
path: middleware.route.path,
methods: Object.keys(middleware.route.methods)
});
} else if (middleware.name === 'router') {
middleware.handle.stack.forEach(handler => {
if (handler.route) {
const fullPath = (middleware.regexp.source === '^\\/?(?=\\/|$)' ? '' : middleware.regexp.source.replace(/\\\//g, '/').replace(/\^|\$/g, '')) + handler.route.path;
routes.push({
path: fullPath,
methods: Object.keys(handler.route.methods)
});
}
});
}
});
res.json(routes);
});
// Test endpoint to verify server is running
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// Mount all routes under /api
console.log('Mounting routes...');
console.log('Mounting products routes...');
app.use('/api/products', productsRouter); app.use('/api/products', productsRouter);
console.log('Mounting dashboard routes...');
app.use('/api/dashboard', dashboardRouter); app.use('/api/dashboard', dashboardRouter);
console.log('Mounting orders routes...');
app.use('/api/orders', ordersRouter); app.use('/api/orders', ordersRouter);
const PORT = process.env.PORT || 3001; console.log('Mounting CSV routes...');
app.use('/api/csv', csvRoutes);
console.log('CSV routes mounted');
console.log('All routes mounted');
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({ error: err.message });
});
// 404 handler
app.use((req, res) => {
console.log('404 Not Found:', req.method, req.path);
res.status(404).json({ error: 'Not Found' });
});
const PORT = process.env.PORT || 3010;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`); console.log(`Server is running on port ${PORT}`);
console.log('Available routes:');
console.log('- GET /api/health');
console.log('- GET /api/debug/routes');
console.log('- GET /api/csv/status');
console.log('- GET /api/csv/test');
console.log('- POST /api/csv/update');
}); });

View File

@@ -0,0 +1,96 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const path = require('path');
// Debug middleware MUST be first
router.use((req, res, next) => {
console.log(`[CSV Route Debug] ${req.method} ${req.path}`);
next();
});
// Store active import process and its progress
let activeImport = null;
let importProgress = null;
// SSE clients for progress updates
const clients = new Set();
// Helper to send progress to all connected clients
function sendProgressToClients(progress) {
clients.forEach(client => {
client.write(`data: ${JSON.stringify(progress)}\n\n`);
});
}
// Debug endpoint to verify route registration
router.get('/test', (req, res) => {
console.log('CSV test endpoint hit');
res.json({ message: 'CSV routes are working' });
});
// Route to check import status
router.get('/status', (req, res) => {
console.log('CSV status endpoint hit');
res.json({
active: !!activeImport,
progress: importProgress
});
});
// Route to update CSV files
router.post('/update', async (req, res) => {
console.log('CSV update endpoint hit');
if (activeImport) {
console.log('Import already in progress');
return res.status(409).json({ error: 'Import already in progress' });
}
try {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'update-csv.js');
console.log('Running script:', scriptPath);
if (!require('fs').existsSync(scriptPath)) {
console.error('Script not found:', scriptPath);
return res.status(500).json({ error: 'Update script not found' });
}
activeImport = spawn('node', [scriptPath]);
activeImport.stdout.on('data', (data) => {
console.log(`CSV Update: ${data}`);
importProgress = data.toString();
sendProgressToClients({ status: 'running', progress: importProgress });
});
activeImport.stderr.on('data', (data) => {
console.error(`CSV Update Error: ${data}`);
sendProgressToClients({ status: 'error', error: data.toString() });
});
await new Promise((resolve, reject) => {
activeImport.on('close', (code) => {
console.log(`CSV update process exited with code ${code}`);
if (code === 0) {
sendProgressToClients({ status: 'complete' });
resolve();
} else {
sendProgressToClients({ status: 'error', error: `Process exited with code ${code}` });
reject(new Error(`Update process exited with code ${code}`));
}
activeImport = null;
importProgress = null;
});
});
res.json({ success: true });
} catch (error) {
console.error('Error updating CSV files:', error);
activeImport = null;
importProgress = null;
res.status(500).json({ error: 'Failed to update CSV files', details: error.message });
}
});
module.exports = router;

View File

@@ -41,6 +41,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.14",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
@@ -2558,6 +2559,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.17.14",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz",
"integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.5", "version": "22.10.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",

View File

@@ -43,6 +43,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.14",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",

View File

@@ -5,6 +5,7 @@ import { Products } from './pages/Products';
import { Import } from './pages/Import'; import { Import } from './pages/Import';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { Orders } from './pages/Orders'; import { Orders } from './pages/Orders';
import { Settings } from './pages/Settings';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -18,6 +19,7 @@ function App() {
<Route path="/products" element={<Products />} /> <Route path="/products" element={<Products />} />
<Route path="/import" element={<Import />} /> <Route path="/import" element={<Import />} />
<Route path="/orders" element={<Orders />} /> <Route path="/orders" element={<Orders />} />
<Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</MainLayout> </MainLayout>
</Router> </Router>

View File

@@ -44,7 +44,7 @@ export function SalesByCategory() {
nameKey="category" nameKey="category"
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`} label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
> >
{data?.map((entry, index) => ( {data?.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))} ))}
</Pie> </Pie>

View File

@@ -2,7 +2,6 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { X } from "lucide-react";
interface ProductFilters { interface ProductFilters {
search: string; search: string;

View File

@@ -59,10 +59,6 @@ export function ProductTable({
return <Badge variant="secondary">In Stock</Badge>; return <Badge variant="secondary">In Stock</Badge>;
}; };
const getProfitMargin = (price: number, cost: number) => {
if (!price || !cost) return 0;
return ((price - cost) / price) * 100;
};
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">

View File

@@ -1,4 +1,3 @@
import * as React from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react"; import { Calendar as CalendarIcon } from "lucide-react";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
@@ -13,7 +12,7 @@ import {
interface DateRangePickerProps { interface DateRangePickerProps {
value: DateRange; value: DateRange;
onChange: (range: DateRange) => void; onChange: (range: DateRange | undefined) => void;
className?: string; className?: string;
} }
@@ -55,7 +54,9 @@ export function DateRangePicker({
mode="range" mode="range"
defaultMonth={value?.from} defaultMonth={value?.from}
selected={value} selected={value}
onSelect={onChange} onSelect={(range) => {
if (range) onChange(range);
}}
numberOfMonths={2} numberOfMonths={2}
/> />
</PopoverContent> </PopoverContent>

View File

@@ -1,15 +1,5 @@
interface Config { const config = {
apiUrl: string;
}
const development: Config = {
apiUrl: 'https://inventory.kent.pw/api' apiUrl: 'https://inventory.kent.pw/api'
}; };
const production: Config = {
apiUrl: 'https://inventory.kent.pw/api'
};
const config: Config = import.meta.env.PROD ? production : development;
export default config; export default config;

View File

@@ -32,6 +32,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUpDown, Search } from "lucide-react"; import { ArrowUpDown, Search } from "lucide-react";
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import config from '../config'; import config from '../config';
import { DateRange } from 'react-day-picker';
interface Order { interface Order {
order_number: string; order_number: string;
@@ -47,7 +48,7 @@ interface Order {
interface OrderFilters { interface OrderFilters {
search: string; search: string;
status: string; status: string;
dateRange: { from: Date | null; to: Date | null }; dateRange: DateRange;
minAmount: string; minAmount: string;
maxAmount: string; maxAmount: string;
} }
@@ -59,7 +60,7 @@ export function Orders() {
const [filters, setFilters] = useState<OrderFilters>({ const [filters, setFilters] = useState<OrderFilters>({
search: '', search: '',
status: 'all', status: 'all',
dateRange: { from: null, to: null }, dateRange: { from: undefined, to: undefined },
minAmount: '', minAmount: '',
maxAmount: '', maxAmount: '',
}); });
@@ -218,7 +219,7 @@ export function Orders() {
</Select> </Select>
<DateRangePicker <DateRangePicker
value={filters.dateRange} value={filters.dateRange}
onChange={(range) => debouncedFilterChange({ dateRange: range })} onChange={(range: DateRange | undefined) => debouncedFilterChange({ dateRange: range })}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
@@ -285,33 +286,36 @@ export function Orders() {
</div> </div>
{data?.pagination.pages > 1 && ( {data?.pagination.pages > 1 && (
<Pagination> <Pagination>
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationPrevious <PaginationPrevious
onClick={() => setPage(p => Math.max(1, p - 1))} aria-disabled={page === 1 || isFetching}
disabled={page === 1 || isFetching} className={page === 1 || isFetching ? 'pointer-events-none opacity-50' : ''}
/> onClick={() => setPage(p => Math.max(1, p - 1))}
</PaginationItem> />
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => ( </PaginationItem>
<PaginationItem key={p}> {Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
<PaginationLink <PaginationItem key={p}>
onClick={() => setPage(p)} <PaginationLink
isActive={p === page} isActive={p === page}
disabled={isFetching} aria-disabled={isFetching}
> className={isFetching ? 'pointer-events-none opacity-50' : ''}
{p} onClick={() => setPage(p)}
</PaginationLink> >
</PaginationItem> {p}
))} </PaginationLink>
<PaginationItem> </PaginationItem>
<PaginationNext ))}
onClick={() => setPage(p => Math.min(data.pagination.pages, p + 1))} <PaginationItem>
disabled={page === data.pagination.pages || isFetching} <PaginationNext
/> aria-disabled={page === data.pagination.pages || isFetching}
</PaginationItem> className={page === data.pagination.pages || isFetching ? 'pointer-events-none opacity-50' : ''}
</PaginationContent> onClick={() => setPage(p => Math.min(data.pagination.pages, p + 1))}
</Pagination> />
</PaginationItem>
</PaginationContent>
</Pagination>
)} )}
</div> </div>
); );

View File

@@ -222,8 +222,9 @@ export function Products() {
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationPrevious <PaginationPrevious
aria-disabled={page === 1 || isFetching}
className={page === 1 || isFetching ? 'pointer-events-none opacity-50' : ''}
onClick={() => handlePageChange(Math.max(1, page - 1))} onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page === 1 || isFetching}
/> />
</PaginationItem> </PaginationItem>
@@ -232,7 +233,8 @@ export function Products() {
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={() => handlePageChange(1)} onClick={() => handlePageChange(1)}
disabled={isFetching} aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
> >
1 1
</PaginationLink> </PaginationLink>
@@ -246,7 +248,8 @@ export function Products() {
<PaginationLink <PaginationLink
onClick={() => handlePageChange(p)} onClick={() => handlePageChange(p)}
isActive={p === currentPage} isActive={p === currentPage}
disabled={isFetching} aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
> >
{p} {p}
</PaginationLink> </PaginationLink>
@@ -259,7 +262,8 @@ export function Products() {
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={() => handlePageChange(totalPages)} onClick={() => handlePageChange(totalPages)}
disabled={isFetching} aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
> >
{totalPages} {totalPages}
</PaginationLink> </PaginationLink>
@@ -269,8 +273,9 @@ export function Products() {
<PaginationItem> <PaginationItem>
<PaginationNext <PaginationNext
aria-disabled={page === data.pagination.pages || isFetching}
className={page === data.pagination.pages || isFetching ? 'pointer-events-none opacity-50' : ''}
onClick={() => handlePageChange(Math.min(data.pagination.pages, page + 1))} onClick={() => handlePageChange(Math.min(data.pagination.pages, page + 1))}
disabled={page === data.pagination.pages || isFetching}
/> />
</PaginationItem> </PaginationItem>
</PaginationContent> </PaginationContent>

View File

@@ -0,0 +1,157 @@
import { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Loader2, RefreshCw, Upload } from "lucide-react";
import config from '../config';
interface ImportProgress {
operation: string;
current: number;
total: number;
rate: number;
elapsed: string;
remaining: string;
}
export function Settings() {
const [isUpdating, setIsUpdating] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState<ImportProgress | null>(null);
const handleUpdateCSV = async () => {
setIsUpdating(true);
try {
const response = await fetch(`${config.apiUrl}/csv/update`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to update CSV files');
}
// After successful update, trigger import
handleImportCSV();
} catch (error) {
console.error('Error updating CSV files:', error);
} finally {
setIsUpdating(false);
}
};
const handleImportCSV = async () => {
setIsImporting(true);
try {
const response = await fetch(`${config.apiUrl}/csv/import`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to start CSV import');
}
// Set up SSE connection for progress updates
const eventSource = new EventSource(`${config.apiUrl}/csv/import/progress`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
setProgress(data);
if (data.operation === 'complete') {
eventSource.close();
setIsImporting(false);
setProgress(null);
}
};
eventSource.onerror = () => {
eventSource.close();
setIsImporting(false);
setProgress(null);
};
} catch (error) {
console.error('Error importing CSV files:', error);
setIsImporting(false);
}
};
const renderProgress = () => {
if (!progress) return null;
const percentage = (progress.current / progress.total) * 100;
return (
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>{progress.operation}</span>
<span>{Math.round(percentage)}%</span>
</div>
<Progress value={percentage} className="h-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{progress.current.toLocaleString()} / {progress.total.toLocaleString()} rows</span>
<span>{Math.round(progress.rate)}/s</span>
</div>
<div className="flex justify-between text-sm text-muted-foreground">
<span>Elapsed: {progress.elapsed}</span>
<span>Remaining: {progress.remaining}</span>
</div>
</div>
);
};
return (
<div className="p-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Settings</h1>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Data Management</CardTitle>
<CardDescription>Update and import CSV data files</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<Button
className="w-full"
onClick={handleUpdateCSV}
disabled={isUpdating || isImporting}
>
{isUpdating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating CSV Files...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Update CSV Files
</>
)}
</Button>
<Button
className="w-full"
onClick={handleImportCSV}
disabled={isUpdating || isImporting}
>
{isImporting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importing Data...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Import Data
</>
)}
</Button>
{(isUpdating || isImporting) && renderProgress()}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/mainlayout.tsx","./src/lib/utils.ts","./src/pages/import.tsx","./src/pages/products.tsx"],"version":"5.6.3"} {"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/dashboard/inventorystats.tsx","./src/components/dashboard/overview.tsx","./src/components/dashboard/recentsales.tsx","./src/components/dashboard/salesbycategory.tsx","./src/components/dashboard/trendingproducts.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/products/producteditdialog.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-mobile.tsx","./src/lib/utils.ts","./src/pages/dashboard.tsx","./src/pages/import.tsx","./src/pages/orders.tsx","./src/pages/products.tsx","./src/pages/settings.tsx"],"version":"5.6.3"}

View File

@@ -92,7 +92,7 @@ export default defineConfig(function (_a) {
port: 5173, port: 5173,
proxy: isDev ? { proxy: isDev ? {
"/api": { "/api": {
target: "http://localhost:3010", target: "https://inventory.kent.pw",
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: function (path) { return path.replace(/^\/api/, "/api"); }, rewrite: function (path) { return path.replace(/^\/api/, "/api"); },