feat: convert server to TypeScript + add ERP API proxy

- Migrate server/index.js → modular TS structure (config, routes, services)
- Add ERP proxy: GET /api/erp/sales proxies Hono ERP API with server-side auth
- JWT token cached server-side, auto-refreshes on 401
- ERP credentials stay server-side only (no VITE_ prefix)
- Vite dev proxy routes /api/erp → localhost:3001
- Preserve existing Salla OAuth integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-26 14:58:35 +03:00
parent 9c1552e439
commit e84d961536
11 changed files with 1111 additions and 10 deletions

25
server/src/config.ts Normal file
View File

@@ -0,0 +1,25 @@
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: resolve(__dirname, '..', '.env') });
export const server = {
port: parseInt(process.env.SERVER_PORT || '3001', 10),
};
export const salla = {
clientId: process.env.SALLA_CLIENT_ID || '',
clientSecret: process.env.SALLA_CLIENT_SECRET || '',
redirectUri: process.env.SALLA_REDIRECT_URI || 'http://localhost:3001/auth/callback',
accessToken: process.env.SALLA_ACCESS_TOKEN || '',
refreshToken: process.env.SALLA_REFRESH_TOKEN || '',
};
export const erp = {
apiUrl: process.env.ERP_API_URL || '',
apiCode: process.env.ERP_API_CODE || '',
username: process.env.ERP_USERNAME || '',
password: process.env.ERP_PASSWORD || '',
};

33
server/src/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import express from 'express';
import cors from 'cors';
import { server, salla, erp } from './config';
import sallaRoutes from './routes/salla';
import erpRoutes from './routes/erp';
const app = express();
app.use(cors());
app.use(express.json());
// Mount routes
app.use(sallaRoutes);
app.use('/api/erp', erpRoutes);
app.listen(server.port, () => {
console.log(`\nServer running on http://localhost:${server.port}`);
console.log('\nERP API:');
if (erp.apiUrl && erp.username) {
console.log(` GET /api/erp/sales?startDate=...&endDate=...`);
console.log(` GET /api/erp/status`);
} else {
console.log(' WARNING: ERP_API_URL / ERP_USERNAME not set in .env');
}
console.log('\nSalla:');
if (salla.clientId && salla.clientSecret) {
console.log(' GET /auth/login, /auth/callback, /auth/status');
console.log(' GET /api/store, /api/orders, /api/products, /api/customers');
} else {
console.log(' WARNING: SALLA_CLIENT_ID / SALLA_CLIENT_SECRET not set in .env');
}
});

34
server/src/routes/erp.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Router, Request, Response } from 'express';
import { fetchSales, isConfigured } from '../services/erpClient';
const router = Router();
// GET /api/erp/sales?startDate=2025-01-01T00:00:00&endDate=2025-01-31T00:00:00
router.get('/sales', async (req: Request, res: Response) => {
if (!isConfigured()) {
res.status(503).json({ error: 'ERP API not configured on server' });
return;
}
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
res.status(400).json({ error: 'startDate and endDate query params required' });
return;
}
try {
const data = await fetchSales(startDate as string, endDate as string);
res.json(data);
} catch (err) {
console.error('ERP fetch error:', (err as Error).message);
res.status(502).json({ error: 'Failed to fetch from ERP API', details: (err as Error).message });
}
});
// GET /api/erp/status
router.get('/status', (_req: Request, res: Response) => {
res.json({ configured: isConfigured() });
});
export default router;

160
server/src/routes/salla.ts Normal file
View File

@@ -0,0 +1,160 @@
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import axios from 'axios';
import { salla } from '../config';
import { getAuthStatus, setTokens, callSallaAPI } from '../services/sallaClient';
const router = Router();
let oauthState: string | null = null;
// OAuth: redirect to Salla authorization
router.get('/auth/login', (_req: Request, res: Response) => {
oauthState = crypto.randomBytes(16).toString('hex');
const authUrl =
`https://accounts.salla.sa/oauth2/auth?` +
`client_id=${salla.clientId}` +
`&redirect_uri=${encodeURIComponent(salla.redirectUri)}` +
`&response_type=code` +
`&scope=offline_access` +
`&state=${oauthState}`;
res.redirect(authUrl);
});
// OAuth: handle callback
router.get('/auth/callback', async (req: Request, res: Response) => {
const { code, error, state } = req.query;
if (error) {
res.status(400).json({ error: 'Authorization denied', details: error });
return;
}
if (!code) {
res.status(400).json({ error: 'No authorization code received' });
return;
}
if (oauthState && state && state !== oauthState) {
res.status(400).json({ error: 'Invalid state parameter' });
return;
}
try {
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
client_id: salla.clientId,
client_secret: salla.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: salla.redirectUri,
});
setTokens(response.data.access_token, response.data.refresh_token);
console.log('\n========================================');
console.log('SALLA CONNECTED SUCCESSFULLY!');
console.log('========================================');
console.log('Add these to your .env file:');
console.log(`SALLA_ACCESS_TOKEN=${response.data.access_token}`);
console.log(`SALLA_REFRESH_TOKEN=${response.data.refresh_token}`);
console.log('========================================\n');
res.send(`
<html>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1>Salla Connected!</h1>
<p>Authorization successful. You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
} catch (err: unknown) {
const axiosErr = err as { response?: { data: unknown }; message: string };
console.error('Token exchange failed:', axiosErr.response?.data || axiosErr.message);
res.status(500).json({ error: 'Token exchange failed', details: axiosErr.response?.data });
}
});
// Auth status
router.get('/auth/status', (_req: Request, res: Response) => {
res.json(getAuthStatus());
});
// Salla API proxy endpoints
router.get('/api/store', async (_req: Request, res: Response) => {
try {
const data = await callSallaAPI('/store/info');
res.json(data);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/api/orders', async (req: Request, res: Response) => {
try {
const { page = 1, per_page = 50, status } = req.query;
let endpoint = `/orders?page=${page}&per_page=${per_page}`;
if (status) endpoint += `&status=${status}`;
const data = await callSallaAPI(endpoint);
res.json(data);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/api/orders/:id', async (req: Request, res: Response) => {
try {
const data = await callSallaAPI(`/orders/${req.params.id}`);
res.json(data);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/api/products', async (req: Request, res: Response) => {
try {
const { page = 1, per_page = 50 } = req.query;
const data = await callSallaAPI(`/products?page=${page}&per_page=${per_page}`);
res.json(data);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/api/customers', async (req: Request, res: Response) => {
try {
const { page = 1, per_page = 50 } = req.query;
const data = await callSallaAPI(`/customers?page=${page}&per_page=${per_page}`);
res.json(data);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/api/analytics/summary', async (_req: Request, res: Response) => {
try {
const [orders, products] = await Promise.all([
callSallaAPI('/orders?per_page=100') as Promise<{ data?: Array<{ amounts?: { total?: { amount?: number; currency?: string } } }>; pagination?: { total?: number } }>,
callSallaAPI('/products?per_page=100') as Promise<{ data?: unknown[]; pagination?: { total?: number } }>,
]);
const ordersList = orders.data || [];
const totalRevenue = ordersList.reduce((sum: number, o) => sum + (o.amounts?.total?.amount || 0), 0);
const avgOrderValue = ordersList.length > 0 ? totalRevenue / ordersList.length : 0;
res.json({
orders: { total: orders.pagination?.total || ordersList.length, recent: ordersList.length },
products: { total: products.pagination?.total || (products.data?.length || 0) },
revenue: {
total: totalRevenue,
average_order: avgOrderValue,
currency: ordersList[0]?.amounts?.total?.currency || 'SAR',
},
});
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@@ -0,0 +1,62 @@
import { erp } from '../config';
let cachedToken: string | null = null;
async function login(): Promise<string> {
const res = await fetch(`${erp.apiUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: erp.username, password: erp.password }),
});
if (!res.ok) {
throw new Error(`ERP login failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
cachedToken = data.token;
return cachedToken;
}
async function getToken(): Promise<string> {
if (cachedToken) return cachedToken;
return login();
}
export async function fetchSales(startDate: string, endDate: string): Promise<unknown[]> {
const token = await getToken();
const url = new URL(`${erp.apiUrl}/api/getbydate`);
url.searchParams.set('startDate', startDate);
url.searchParams.set('endDate', endDate);
url.searchParams.set('code', erp.apiCode);
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
});
// Token expired — re-login and retry once
if (res.status === 401) {
cachedToken = null;
const freshToken = await login();
const retry = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${freshToken}` },
});
if (!retry.ok) {
throw new Error(`ERP fetch failed after re-login: ${retry.status}`);
}
return retry.json();
}
if (!res.ok) {
throw new Error(`ERP fetch failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
export function isConfigured(): boolean {
return !!(erp.apiUrl && erp.apiCode && erp.username && erp.password);
}

View File

@@ -0,0 +1,60 @@
import axios from 'axios';
import { salla } from '../config';
let accessToken = salla.accessToken || null;
let refreshToken = salla.refreshToken || null;
export function getAuthStatus() {
return { connected: !!accessToken, hasRefreshToken: !!refreshToken };
}
export function setTokens(access: string, refresh?: string) {
accessToken = access;
if (refresh) refreshToken = refresh;
}
export async function refreshAccessToken(): Promise<string> {
if (!refreshToken) throw new Error('No refresh token available');
const response = await axios.post('https://accounts.salla.sa/oauth2/token', {
client_id: salla.clientId,
client_secret: salla.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
accessToken = response.data.access_token;
if (response.data.refresh_token) {
refreshToken = response.data.refresh_token;
}
return accessToken!;
}
export async function callSallaAPI(
endpoint: string,
method: 'GET' | 'POST' = 'GET',
data: unknown = null
): Promise<unknown> {
if (!accessToken) throw new Error('Not authenticated. Visit /auth/login first.');
try {
const response = await axios({
method,
url: `https://api.salla.dev/admin/v2${endpoint}`,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
data,
});
return response.data;
} catch (err: unknown) {
const axiosErr = err as { response?: { status: number } };
if (axiosErr.response?.status === 401) {
await refreshAccessToken();
return callSallaAPI(endpoint, method, data);
}
throw err;
}
}