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:
25
server/src/config.ts
Normal file
25
server/src/config.ts
Normal 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
33
server/src/index.ts
Normal 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
34
server/src/routes/erp.ts
Normal 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
160
server/src/routes/salla.ts
Normal 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;
|
||||
62
server/src/services/erpClient.ts
Normal file
62
server/src/services/erpClient.ts
Normal 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);
|
||||
}
|
||||
60
server/src/services/sallaClient.ts
Normal file
60
server/src/services/sallaClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user